feat(hub): Korrektheits- und Realtime-Härtung — Alias, Lifecycle, Presence, Architekten-Pulse (TSK-0242/0245/0249)

Drei vom Architekten abgenommene Tasks, gebündelt als Checkpoint:

- TSK-0242: Agent-Alias-Mapping (kimi-ah → kimi kanonisiert, Rollen → preferredAgent),
  reopenTask räumt claimedBy ab, fsWatch reindiziert direkte Datei-Edits,
  work-Default 300s → 50s, task_list mit Limit.
- TSK-0245: zwei Agent-Klassen (dispatch loop|architect). Watchdog mahnt
  architekt-getriebene Agenten nur noch EINMAL statt im Minutentakt;
  `task dispatch` startet sie explizit, `task record` trägt extern
  erledigte Arbeit mit origin=external nach.
- TSK-0249: Lifecycle wird serverseitig erzwungen (open→review scheitert mit
  klarer Meldung), claimedBy/doneBy überleben bis done, Presence pro Agent,
  Review-Watchdog, GET /architect/pulse (1.4 kB statt 34 kB, since-Cursor,
  omitted statt stillem Abschneiden), unbekannter Agent → 400 statt 500.

Alle Punkte live am laufenden Hub nachgemessen, nicht aus Agenten-Logs übernommen.
Tests: 242 → 279 grün, tsc sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-29 17:48:24 +02:00
parent 9dfbf3f657
commit 6d088648da
30 changed files with 986 additions and 83 deletions

View File

@ -28,6 +28,9 @@ export function printHealth(h: HealthReport): void {
`hub: ${h.status} · v${h.version} · up ${fmtDuration(h.uptimeSec)} · ` + `hub: ${h.status} · v${h.version} · up ${fmtDuration(h.uptimeSec)} · ` +
`${c.tasks} tasks (${c.open} open, ${c.inProgress} in progress, ${c.review} review) · ${c.unreadMessages} unread`, `${c.tasks} tasks (${c.open} open, ${c.inProgress} in progress, ${c.review} review) · ${c.unreadMessages} unread`,
); );
for (const error of h.indexErrors ?? []) {
console.log(`! index error · ${error.filePath} · ${error.error}`);
}
if (h.agents.length === 0) { if (h.agents.length === 0) {
console.log('no agents yet'); console.log('no agents yet');
return; return;
@ -36,6 +39,11 @@ export function printHealth(h: HealthReport): void {
for (const a of h.agents) { for (const a of h.agents) {
const light = LIGHT[a.state] ?? '○'; const light = LIGHT[a.state] ?? '○';
const task = a.taskId ? ` ${a.taskId}${a.state === 'stale' ? ' open' : ''} ·` : ''; const task = a.taskId ? ` ${a.taskId}${a.state === 'stale' ? ' open' : ''} ·` : '';
console.log(`${a.name.padEnd(width)} ${light} ${a.state}${task} ${fmtAgo(a.lastSeenAgoSec)}`); const loop = a.inLoop
? ` · in loop ${fmtAgo(a.loopSinceAgoSec)}`
: a.loopExitReason
? ` · out of loop ${fmtAgo(a.loopExitAgoSec)} (${a.loopExitReason})`
: ' · loop unknown';
console.log(`${a.name.padEnd(width)} ${light} ${a.state}${task} ${fmtAgo(a.lastSeenAgoSec)}${loop}`);
} }
} }

View File

@ -5,6 +5,7 @@ import {
claimTask as svcClaimTask, claimTask as svcClaimTask,
} from '../../core/services/taskService.js'; } from '../../core/services/taskService.js';
import { listHandoffs as svcListHandoffs, getHandoff as svcGetHandoff } from '../../core/services/handoffService.js'; import { listHandoffs as svcListHandoffs, getHandoff as svcGetHandoff } from '../../core/services/handoffService.js';
import { agentIdentity } from '../../core/services/identityService.js';
export interface AgentContext { export interface AgentContext {
serverUrl?: string; serverUrl?: string;
@ -79,12 +80,19 @@ const PRIORITY_RANK: Record<string, number> = { critical: 4, high: 3, medium: 2,
export async function findAddressedOpenTask( export async function findAddressedOpenTask(
ctx: AgentContext, ctx: AgentContext,
): Promise<{ task: Listed; handoffs: Listed[] } | undefined> { ): Promise<{ task: Listed; handoffs: Listed[] } | undefined> {
const a = ctx.agent.toLowerCase(); const identity = ctx.serverUrl
? await remoteClient.getAgentIdentity(ctx.serverUrl, ctx.agent).then((value) => ({
canonical: value.canonical,
names: new Set(value.names),
}))
: agentIdentity(ctx.projectCwd, ctx.agent);
const a = identity.canonical.toLowerCase();
const matchesAgent = (value: unknown) => identity.names.has(String(value ?? '').toLowerCase());
const inProgress = ctx.serverUrl const inProgress = ctx.serverUrl
? await remoteClient.listTasks(ctx.serverUrl, { status: 'in_progress' }) ? await remoteClient.listTasks(ctx.serverUrl, { status: 'in_progress' })
: svcListTasks(ctx.projectCwd, { status: 'in_progress' }); : svcListTasks(ctx.projectCwd, { status: 'in_progress' });
const busy = inProgress.some((t) => String(t.claimedBy ?? t.assignedTo ?? '').toLowerCase() === a); const busy = inProgress.some((t) => matchesAgent(t.claimedBy ?? t.assignedTo));
if (busy) return undefined; if (busy) return undefined;
const tasks = await listOpenRoleTasks(ctx); const tasks = await listOpenRoleTasks(ctx);
@ -92,14 +100,14 @@ export async function findAddressedOpenTask(
const addressedByHandoff = new Set( const addressedByHandoff = new Set(
handoffs handoffs
.filter((h) => h.toAgent && String(h.toAgent).toLowerCase() === a && h.taskId) .filter((h) => h.toAgent && matchesAgent(h.toAgent) && h.taskId)
.map((h) => String(h.taskId)), .map((h) => String(h.taskId)),
); );
const mine = tasks.filter( const mine = tasks.filter(
(t) => (t) =>
(t.title ?? '').toLowerCase().startsWith(`${a}:`) || (t.title ?? '').toLowerCase().startsWith(`${a}:`) ||
addressedByHandoff.has(t.id) || addressedByHandoff.has(t.id) ||
(t.assignedTo && String(t.assignedTo).toLowerCase() === a), (t.assignedTo && matchesAgent(t.assignedTo)),
); );
if (mine.length === 0) return undefined; if (mine.length === 0) return undefined;

View File

@ -1,5 +1,5 @@
import { input, select } from '@inquirer/prompts'; import { input, select } from '@inquirer/prompts';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js'; import { createTask, recordExternalTask, dispatchTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
import { appendTaskLog } from '../../core/services/taskLogService.js'; import { appendTaskLog } from '../../core/services/taskLogService.js';
import type { Task } from '../../core/schema.js'; import type { Task } from '../../core/schema.js';
@ -42,6 +42,14 @@ export function taskClaim(cwd: string, id: string, agentName: string): void {
claimTask(cwd, id, agentName); claimTask(cwd, id, agentName);
console.log(`AgentHub: Task claimed ${id} by ${agentName}`); console.log(`AgentHub: Task claimed ${id} by ${agentName}`);
} }
export function taskDispatch(cwd: string, id: string, agentName: string): void {
dispatchTask(cwd, id, agentName);
console.log(`AgentHub: Architect started ${agentName} for ${id}`);
}
export function taskRecord(cwd: string, options: { title: string; doneBy: string; description?: string; role?: Task['role']; priority?: Task['priority'] }): void {
const task = recordExternalTask(cwd, options);
console.log(`AgentHub: External work recorded ${task.id} by ${task.doneBy}`);
}
export function taskDone( export function taskDone(
cwd: string, cwd: string,

View File

@ -2,7 +2,7 @@ import { Command } from 'commander';
import { init } from './commands/init.js'; import { init } from './commands/init.js';
import { status } from './commands/status.js'; import { status } from './commands/status.js';
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js'; import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js'; import { taskCreate, taskList, taskShow, taskClaim, taskDispatch, taskRecord, taskDone, taskReview, taskReopen, taskAssign, taskLog } 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, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js'; import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
@ -336,6 +336,37 @@ export function createProgram(cwd: string): Command {
taskClaim(projectCwd, id, options.agent); taskClaim(projectCwd, id, options.agent);
} }
}); });
taskCmd
.command('dispatch <id>')
.description('Architect: explicitly start a session-less agent for an open task')
.requiredOption('--agent <agent>', 'Architect-dispatched agent')
.action(async (id, options) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
await remoteClient.dispatchTask(serverUrl, id, options.agent);
console.log(`AgentHub: Architect started ${options.agent} for ${id}`);
});
} else taskDispatch(projectCwd, id, options.agent);
});
taskCmd
.command('record')
.description('Record work already completed outside AgentHub')
.requiredOption('--title <title>', 'Completed work')
.requiredOption('--by <agent>', 'Who completed it')
.option('--description <description>', 'Details')
.option('--role <role>', 'Role')
.option('--priority <priority>', 'Priority')
.action(async (options) => {
const payload = { title: options.title, doneBy: options.by, description: options.description, role: options.role, priority: options.priority };
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const task = await remoteClient.recordExternalTask(serverUrl, payload);
console.log(`AgentHub: External work recorded ${task.id} by ${task.doneBy}`);
});
} else taskRecord(projectCwd, payload);
});
taskCmd taskCmd
.command('done <id>') .command('done <id>')
.description('Mark a task as done') .description('Mark a task as done')

View File

@ -43,10 +43,27 @@ export const remoteClient = {
return request<HealthReport>(baseUrl, 'GET', '/health'); return request<HealthReport>(baseUrl, 'GET', '/health');
}, },
async getAgentIdentity(baseUrl: string, agent: string): Promise<{ canonical: string; names: string[] }> {
return request<{ canonical: string; names: string[] }>(
baseUrl,
'GET',
`/agents/${encodeURIComponent(agent)}/identity`,
);
},
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> { async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action }); await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
}, },
async setLoop(baseUrl: string, agent: string, active: boolean, reason?: string): Promise<void> {
await request<{ ok: boolean }>(
baseUrl,
'POST',
`/agents/${encodeURIComponent(agent)}/loop`,
{ active, reason },
);
},
async updateStatus(baseUrl: string): Promise<string> { async updateStatus(baseUrl: string): Promise<string> {
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body); return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
}, },
@ -54,6 +71,9 @@ export const remoteClient = {
async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> { async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> {
return request<Task>(baseUrl, 'POST', '/tasks', options); return request<Task>(baseUrl, 'POST', '/tasks', options);
}, },
async recordExternalTask(baseUrl: string, options: Partial<Task> & { title: string; doneBy: string }): Promise<Task> {
return request<Task>(baseUrl, 'POST', '/tasks/record', options);
},
async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> { async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> {
const params = new URLSearchParams((filters ?? {}) as Record<string, string>); const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
@ -68,6 +88,9 @@ export const remoteClient = {
async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> { async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName }); return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
}, },
async dispatchTask(baseUrl: string, id: string, agent: string): Promise<Task> {
return request<Task>(baseUrl, 'POST', `/tasks/${id}/dispatch`, { agent });
},
async doneTask( async doneTask(
baseUrl: string, baseUrl: string,

View File

@ -6,6 +6,7 @@ export const MemoryCategory = z.enum(['architecture', 'product', 'technical', 'i
export const Priority = z.enum(['low', 'medium', 'high', 'critical']); export const Priority = z.enum(['low', 'medium', 'high', 'critical']);
export const Role = z.enum(['architect', 'implementer', 'reviewer', 'tester']); export const Role = z.enum(['architect', 'implementer', 'reviewer', 'tester']);
export const DelegationMode = z.enum(['manual', 'suggest', 'auto']); export const DelegationMode = z.enum(['manual', 'suggest', 'auto']);
export const AgentDispatch = z.enum(['loop', 'architect']);
export const TaskSchema = z.object({ export const TaskSchema = z.object({
id: z.string().regex(/^TSK-\d{4}$/), id: z.string().regex(/^TSK-\d{4}$/),
@ -27,6 +28,9 @@ export const TaskSchema = z.object({
doneBy: z.string().optional(), doneBy: z.string().optional(),
doneTokens: z.number().int().nonnegative().optional(), doneTokens: z.number().int().nonnegative().optional(),
doneDuration: z.number().int().nonnegative().optional(), doneDuration: z.number().int().nonnegative().optional(),
/** Honest provenance for work recorded after it happened outside AgentHub. */
origin: z.enum(['agenthub', 'external']).default('agenthub'),
recordedAt: z.string().datetime().optional(),
}); });
export const HandoffSchema = z.object({ export const HandoffSchema = z.object({
@ -154,6 +158,10 @@ export const RoleConfigSchema = z.object({
*/ */
export const AgentConfigSchema = z.object({ export const AgentConfigSchema = z.object({
role: Role, role: Role,
/** loop = self-claiming session; architect = spawned/claimed explicitly by the architect. */
dispatch: AgentDispatch.default('loop'),
/** Previous/alternate names that resolve to this canonical roster key. */
aliases: z.array(z.string().min(1)).default([]),
/** Display model, e.g. "Opus 4.8", "Sonnet 4.6", "Kimi K2", "GPT-5 Codex". */ /** Display model, e.g. "Opus 4.8", "Sonnet 4.6", "Kimi K2", "GPT-5 Codex". */
model: z.string().optional(), model: z.string().optional(),
/** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */ /** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */
@ -184,6 +192,8 @@ export const OrgNodeSchema = z.object({
parentId: z.string().optional(), parentId: z.string().optional(),
/** Provider override for the icon when there is no linked agent. */ /** Provider override for the icon when there is no linked agent. */
kind: z.string().optional(), kind: z.string().optional(),
/** When linked to an agent, org sync copies this into the roster. */
dispatch: AgentDispatch.optional(),
}); });
export type OrgNode = z.infer<typeof OrgNodeSchema>; export type OrgNode = z.infer<typeof OrgNodeSchema>;
@ -202,6 +212,8 @@ export const WatchdogConfigSchema = z.object({
unclaimedDefaultMs: z.number().int().positive().default(10 * 60_000), unclaimedDefaultMs: z.number().int().positive().default(10 * 60_000),
/** IN_PROGRESS with no task-log line for this long → alert the architect. */ /** IN_PROGRESS with no task-log line for this long → alert the architect. */
staleInProgressMs: z.number().int().positive().default(15 * 60_000), staleInProgressMs: z.number().int().positive().default(15 * 60_000),
/** REVIEW untouched for this long → remind the architect. */
staleReviewMs: z.number().int().positive().default(5 * 60_000),
}).default({}); }).default({});
export const ConfigSchema = z.object({ export const ConfigSchema = z.object({

View File

@ -4,15 +4,17 @@ import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js'; import { readEntity, writeEntity } from '../files.js';
import { HandoffSchema, type Handoff } from '../schema.js'; import { HandoffSchema, type Handoff } from '../schema.js';
import { Index } from '../index.js'; import { Index } from '../index.js';
import { resolveAgentName } from './identityService.js';
export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Handoff { export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Handoff {
const now = new Date().toISOString(); const now = new Date().toISOString();
const toAgent = options.toAgent ? resolveAgentName(cwd, options.toAgent) : undefined;
const handoff: Handoff = HandoffSchema.parse({ const handoff: Handoff = HandoffSchema.parse({
id: getNextId(cwd, 'handoff'), id: getNextId(cwd, 'handoff'),
fromRole: options.fromRole ?? 'user', fromRole: options.fromRole ?? 'user',
toRole: options.toRole ?? 'user', toRole: options.toRole ?? 'user',
fromAgent: options.fromAgent, fromAgent: options.fromAgent,
toAgent: options.toAgent, toAgent,
taskId: options.taskId, taskId: options.taskId,
summary: options.summary ?? 'Handoff', summary: options.summary ?? 'Handoff',
context: options.context ?? '', context: options.context ?? '',

View File

@ -0,0 +1,49 @@
import { loadConfig } from '../config.js';
export interface AgentIdentity {
canonical: string;
names: Set<string>;
}
/**
* Resolve an agent, configured alias, or role name to the canonical roster
* agent. Projects without an explicit roster keep accepting free-form agent
* names for backward compatibility.
*/
export function resolveAgentName(cwd: string, recipient: string): string {
const raw = recipient?.trim();
if (!raw) throw new Error('Agent recipient is required');
const key = raw.toLowerCase();
let config;
try {
config = loadConfig(cwd);
} catch {
return raw;
}
const role = Object.entries(config.roles).find(([name]) => name.toLowerCase() === key);
if (role) return role[1].preferredAgent;
const agents = config.agents;
if (!agents || Object.keys(agents).length === 0) return raw;
for (const [canonical, entry] of Object.entries(agents)) {
if (canonical.toLowerCase() === key || entry.aliases.some((alias) => alias.toLowerCase() === key)) {
return canonical;
}
}
throw new Error(`Unknown agent, alias, or role: "${raw}"`);
}
/** All names that should compare equal for task/handoff/inbox matching. */
export function agentIdentity(cwd: string, recipient: string): AgentIdentity {
const canonical = resolveAgentName(cwd, recipient);
const config = loadConfig(cwd);
const names = new Set<string>([canonical.toLowerCase()]);
const entry = config.agents?.[canonical];
for (const alias of entry?.aliases ?? []) names.add(alias.toLowerCase());
for (const [role, roleConfig] of Object.entries(config.roles)) {
if (roleConfig.preferredAgent.toLowerCase() === canonical.toLowerCase()) names.add(role.toLowerCase());
}
return { canonical, names };
}

View File

@ -4,6 +4,7 @@ import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js'; import { readEntity, writeEntity } from '../files.js';
import { MessageSchema, type Message } from '../schema.js'; import { MessageSchema, type Message } from '../schema.js';
import { Index } from '../index.js'; import { Index } from '../index.js';
import { agentIdentity, resolveAgentName } from './identityService.js';
function indexEntryFor(record: Message, filePath: string) { function indexEntryFor(record: Message, filePath: string) {
return { return {
@ -28,10 +29,11 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
if (!options.text) throw new Error('Message requires text'); if (!options.text) throw new Error('Message requires text');
const now = new Date().toISOString(); const now = new Date().toISOString();
const to = resolveAgentName(cwd, options.to);
const record: Message = MessageSchema.parse({ const record: Message = MessageSchema.parse({
id: getNextId(cwd, 'message'), id: getNextId(cwd, 'message'),
from: options.from, from: options.from,
to: options.to, to,
text: options.text, text: options.text,
taskId: options.taskId, taskId: options.taskId,
replyTo: options.replyTo, replyTo: options.replyTo,
@ -51,14 +53,17 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
} }
/** Agent aliases that should see the same inbox. Keep deliberately small. */ /** Agent aliases that should see the same inbox. Keep deliberately small. */
export function messageRecipientAliases(agent: string): Set<string> { export function messageRecipientAliases(cwdOrAgent: string, agent?: string): Set<string> {
const key = agent.toLowerCase(); if (agent) return agentIdentity(cwdOrAgent, agent).names;
const aliases = new Set([agent, key]); // Remote event filters do not have project config; new writes are already
// canonicalized server-side. Retain the legacy architect pair for old data.
const key = cwdOrAgent.toLowerCase();
const names = new Set([key]);
if (key === 'architect' || key === 'claude') { if (key === 'architect' || key === 'claude') {
aliases.add('architect'); names.add('architect');
aliases.add('claude'); names.add('claude');
} }
return aliases; return names;
} }
export interface InboxMessage { export interface InboxMessage {
@ -89,7 +94,7 @@ 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); const recipients = messageRecipientAliases(cwd, agent);
const filtered = all const filtered = all
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase())) .filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
.filter((m) => !opts.unreadOnly || m.status === 'unread'); .filter((m) => !opts.unreadOnly || m.status === 'unread');

View File

@ -53,6 +53,10 @@ export function syncOrgFromFile(cwd: string, from?: string): { count: number; fi
const org = parseOrg(block); const org = parseOrg(block);
const config = loadConfig(root); const config = loadConfig(root);
config.org = org; config.org = org;
for (const node of org) {
if (!node.agent || !node.dispatch || !config.agents?.[node.agent]) continue;
config.agents[node.agent].dispatch = node.dispatch;
}
saveConfig(root, config); saveConfig(root, config);
return { count: org.length, file }; return { count: org.length, file };
} }

View File

@ -28,11 +28,19 @@ export type AgentLight = 'active' | 'busy' | 'idle' | 'stale';
export interface AgentHealth { export interface AgentHealth {
name: string; name: string;
role: string; role: string;
dispatch: 'loop' | 'architect';
state: AgentLight; state: AgentLight;
/** Busy-on task (in_progress) or — for stale agents — the waiting open task. */ /** Busy-on task (in_progress) or — for stale agents — the waiting open task. */
taskId?: string; taskId?: string;
waitingTaskId?: string;
lastSeen?: string; lastSeen?: string;
lastSeenAgoSec?: number; lastSeenAgoSec?: number;
inLoop: boolean;
loopSince?: string;
loopSinceAgoSec?: number;
loopExitAt?: string;
loopExitAgoSec?: number;
loopExitReason?: string;
} }
export interface HealthReport { export interface HealthReport {
@ -42,9 +50,11 @@ export interface HealthReport {
uptimeSec: number; uptimeSec: number;
counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number }; counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number };
agents: AgentHealth[]; agents: AgentHealth[];
indexErrors?: Array<{ filePath: string; error: string; at: string }>;
} }
const lastSeen = new Map<string, number>(); const lastSeen = new Map<string, number>();
const loops = new Map<string, { since?: number; exitAt?: number; exitReason?: string }>();
/** Stamp an agent's lastSeen (called by the routes on agent actions). */ /** Stamp an agent's lastSeen (called by the routes on agent actions). */
export function stampSeen(agent: string, at: number = Date.now()): void { export function stampSeen(agent: string, at: number = Date.now()): void {
@ -53,9 +63,34 @@ export function stampSeen(agent: string, at: number = Date.now()): void {
lastSeen.set(name, at); lastSeen.set(name, at);
} }
export function enterLoop(agent: string, at: number = Date.now()): void {
const name = agent?.trim();
if (!name) return;
lastSeen.set(name, at);
loops.set(name, { since: at });
}
export function leaveLoop(agent: string, reason: string, at: number = Date.now()): void {
const name = agent?.trim();
if (!name) return;
lastSeen.set(name, at);
loops.set(name, { exitAt: at, exitReason: reason || 'ended' });
}
export function isAgentInLoop(agent: string): boolean {
return loops.get(agent)?.since !== undefined;
}
export function agentLoopStatus(agent: string): 'active' | 'inactive' | 'unknown' {
const loop = loops.get(agent);
if (!loop) return 'unknown';
return loop.since !== undefined ? 'active' : 'inactive';
}
/** Test hook: drop all presence state. */ /** Test hook: drop all presence state. */
export function resetPresence(): void { export function resetPresence(): void {
lastSeen.clear(); lastSeen.clear();
loops.clear();
} }
/** One agent's traffic light, derived from presence + its task load. */ /** One agent's traffic light, derived from presence + its task load. */
@ -63,11 +98,12 @@ export function agentLight(
name: string, name: string,
tasks: Array<{ id: string; status?: string; assignedTo?: string; claimedBy?: string }>, tasks: Array<{ id: string; status?: string; assignedTo?: string; claimedBy?: string }>,
now: number = Date.now(), now: number = Date.now(),
): Pick<AgentHealth, 'state' | 'taskId' | 'lastSeen' | 'lastSeenAgoSec'> { ): Omit<AgentHealth, 'name' | 'role' | 'dispatch'> {
const seen = lastSeen.get(name); const seen = lastSeen.get(name);
const busy = tasks.find((t) => t.status === 'in_progress' && (t.assignedTo === name || t.claimedBy === name)); const busy = tasks.find((t) => t.status === 'in_progress' && (t.assignedTo === name || t.claimedBy === name));
const waiting = tasks.find((t) => t.status === 'open' && t.assignedTo === name); const waiting = tasks.find((t) => t.status === 'open' && t.assignedTo === name);
const seenAgo = seen === undefined ? undefined : now - seen; const seenAgo = seen === undefined ? undefined : now - seen;
const loop = loops.get(name);
let state: AgentLight; let state: AgentLight;
if (seenAgo !== undefined && seenAgo < ACTIVE_WINDOW_MS) { if (seenAgo !== undefined && seenAgo < ACTIVE_WINDOW_MS) {
@ -85,8 +121,15 @@ export function agentLight(
return { return {
state, state,
taskId: busy?.id ?? (state === 'stale' ? waiting?.id : undefined), taskId: busy?.id ?? (state === 'stale' ? waiting?.id : undefined),
waitingTaskId: waiting?.id,
lastSeen: seen === undefined ? undefined : new Date(seen).toISOString(), lastSeen: seen === undefined ? undefined : new Date(seen).toISOString(),
lastSeenAgoSec: seenAgo === undefined ? undefined : Math.max(0, Math.round(seenAgo / 1000)), lastSeenAgoSec: seenAgo === undefined ? undefined : Math.max(0, Math.round(seenAgo / 1000)),
inLoop: loop?.since !== undefined,
loopSince: loop?.since === undefined ? undefined : new Date(loop.since).toISOString(),
loopSinceAgoSec: loop?.since === undefined ? undefined : Math.max(0, Math.round((now - loop.since) / 1000)),
loopExitAt: loop?.exitAt === undefined ? undefined : new Date(loop.exitAt).toISOString(),
loopExitAgoSec: loop?.exitAt === undefined ? undefined : Math.max(0, Math.round((now - loop.exitAt) / 1000)),
loopExitReason: loop?.exitReason,
}; };
} }
@ -95,6 +138,7 @@ export function computeHealth(cwd: string, startedAtMs: number, now: number = Da
const tasks = listTasks(cwd); const tasks = listTasks(cwd);
const roster = getRoster(cwd); const roster = getRoster(cwd);
const roleByName = new Map(roster.map((r) => [r.name, r.role])); const roleByName = new Map(roster.map((r) => [r.name, r.role]));
const dispatchByName = new Map(roster.map((r) => [r.name, r.dispatch]));
// Roster agents PLUS anyone who only ever announced themselves (presence-only). // Roster agents PLUS anyone who only ever announced themselves (presence-only).
const names = new Set<string>([...roster.map((r) => r.name), ...lastSeen.keys()]); const names = new Set<string>([...roster.map((r) => r.name), ...lastSeen.keys()]);
@ -103,6 +147,7 @@ export function computeHealth(cwd: string, startedAtMs: number, now: number = Da
.map((name) => ({ .map((name) => ({
name, name,
role: roleByName.get(name) ?? 'implementer', role: roleByName.get(name) ?? 'implementer',
dispatch: dispatchByName.get(name) ?? 'loop',
...agentLight(name, tasks, now), ...agentLight(name, tasks, now),
})); }));

View File

@ -10,6 +10,7 @@ export interface RosterEntry {
kind?: string; kind?: string;
description?: string; description?: string;
budgetEur?: number; budgetEur?: number;
dispatch: 'loop' | 'architect';
} }
/** Infer a provider ("kind") from an agent name when the roster doesn't say. */ /** Infer a provider ("kind") from an agent name when the roster doesn't say. */
@ -45,6 +46,7 @@ export function getRoster(cwd: string): RosterEntry[] {
kind: a.kind ?? inferKind(name), kind: a.kind ?? inferKind(name),
description: a.description, description: a.description,
budgetEur: a.budgetEur, budgetEur: a.budgetEur,
dispatch: a.dispatch,
})); }));
} }
@ -60,6 +62,6 @@ export function getRoster(cwd: string): RosterEntry[] {
return Array.from(names).map((name) => { return Array.from(names).map((name) => {
const kind = inferKind(name); const kind = inferKind(name);
return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind) }; return { name, role: roleByAgent.get(name) ?? 'implementer', kind, model: inferModel(kind), dispatch: 'loop' };
}); });
} }

View File

@ -6,9 +6,11 @@ import { readEntity, writeEntity } from '../files.js';
import { TaskSchema, type Task } from '../schema.js'; import { TaskSchema, type Task } from '../schema.js';
import { Index } from '../index.js'; import { Index } from '../index.js';
import { loadConfig } from '../config.js'; import { loadConfig } from '../config.js';
import { resolveAgentName } from './identityService.js';
export function createTask(cwd: string, options: Partial<Task> = {}): Task { export function createTask(cwd: string, options: Partial<Task> = {}): Task {
const now = new Date().toISOString(); const now = new Date().toISOString();
const assignedTo = options.assignedTo ? resolveAgentName(cwd, options.assignedTo) : undefined;
const task: Task = TaskSchema.parse({ const task: Task = TaskSchema.parse({
id: getNextId(cwd, 'task'), id: getNextId(cwd, 'task'),
title: options.title ?? 'Untitled', title: options.title ?? 'Untitled',
@ -16,8 +18,8 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
status: 'open', status: 'open',
priority: options.priority ?? 'medium', priority: options.priority ?? 'medium',
role: options.role, role: options.role,
assignedTo: options.assignedTo, assignedTo,
claimedBy: options.claimedBy, claimedBy: options.claimedBy ? resolveAgentName(cwd, options.claimedBy) : undefined,
reviewer: options.reviewer, reviewer: options.reviewer,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -33,6 +35,50 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
return task; return task;
} }
/** Record work completed outside AgentHub without fabricating lifecycle events. */
export function recordExternalTask(
cwd: string,
options: Pick<Task, 'title'> & Partial<Task> & { doneBy: string },
): Task {
const now = new Date().toISOString();
const agent = resolveAgentName(cwd, options.doneBy);
const task: Task = TaskSchema.parse({
id: getNextId(cwd, 'task'),
title: options.title,
description: options.description ?? '',
status: 'done',
priority: options.priority ?? 'medium',
role: options.role ?? 'implementer',
assignedTo: agent,
claimedBy: agent,
doneBy: agent,
doneTokens: options.doneTokens,
doneDuration: options.doneDuration,
origin: 'external',
recordedAt: now,
createdAt: now,
updatedAt: now,
});
const filePath = join(getEntityDir(cwd, 'tasks'), `${task.id}.md`);
writeEntity(filePath, task, `# ${task.title}\n\n${task.description}\n\n> Nachgetragen: außerhalb von AgentHub erledigt durch ${agent}.`);
const index = new Index(cwd);
index.upsert(toIndexEntry(task, filePath));
index.close();
return task;
}
/** Architect explicitly starts a session-less roster agent for an open task. */
export function dispatchTask(cwd: string, id: string, agentName: string): Task {
const agent = resolveAgentName(cwd, agentName);
const roster = loadConfig(cwd).agents?.[agent];
if (roster?.dispatch !== 'architect') {
throw new Error(`${agent} is not architect-dispatched`);
}
const { task } = getTask(cwd, id);
if (task.status !== 'open') throw new Error(`Task ${id} cannot be dispatched from ${task.status}`);
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agent, claimedBy: agent });
}
export function listTasks(cwd: string, filters?: { status?: string; role?: string }): ReturnType<Index['list']> { export function listTasks(cwd: string, filters?: { status?: string; role?: string }): ReturnType<Index['list']> {
const index = new Index(cwd); const index = new Index(cwd);
const tasks = index.list('task', filters); const tasks = index.list('task', filters);
@ -72,6 +118,7 @@ export function updateTask(cwd: string, id: string, patch: Partial<Task>): Task
* then sees `in_progress` and is refused. * then sees `in_progress` and is refused.
*/ */
export function claimTask(cwd: string, id: string, agentName: string): Task { export function claimTask(cwd: string, id: string, agentName: string): Task {
agentName = resolveAgentName(cwd, agentName);
const { task } = getTask(cwd, id); const { task } = getTask(cwd, id);
// Idempotent: the same agent re-claiming its own in-progress task is a no-op. // Idempotent: the same agent re-claiming its own in-progress task is a no-op.
if (task.status === 'in_progress' && task.claimedBy === agentName) { if (task.status === 'in_progress' && task.claimedBy === agentName) {
@ -116,9 +163,16 @@ export function doneTask(
id: string, id: string,
meta?: { doneBy?: string; doneTokens?: number; doneDuration?: number }, meta?: { doneBy?: string; doneTokens?: number; doneDuration?: number },
): Task { ): Task {
const { task } = getTask(cwd, id);
if (task.status !== 'review') {
throw new Error(`Task ${id} cannot transition ${task.status} → done; expected review → done`);
}
if (!task.claimedBy) {
throw new Error(`Task ${id} cannot be completed without a known claimedBy implementer`);
}
return updateTask(cwd, id, { return updateTask(cwd, id, {
status: 'done', status: 'done',
doneBy: meta?.doneBy, doneBy: meta?.doneBy ?? task.claimedBy,
doneTokens: meta?.doneTokens, doneTokens: meta?.doneTokens,
doneDuration: meta?.doneDuration, doneDuration: meta?.doneDuration,
}); });
@ -126,6 +180,12 @@ export function doneTask(
export function reviewTask(cwd: string, id: string, reviewer?: string): Task { export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
const { task } = getTask(cwd, id); const { task } = getTask(cwd, id);
if (task.status !== 'in_progress') {
throw new Error(`Task ${id} cannot transition ${task.status} → review; expected in_progress → review`);
}
if (!task.claimedBy) {
throw new Error(`Task ${id} cannot enter review without a known claimedBy implementer`);
}
const resolvedReviewer = reviewer?.trim() || getPreferredReviewer(cwd); const resolvedReviewer = reviewer?.trim() || getPreferredReviewer(cwd);
const patch: Partial<Task> = { status: 'review' }; const patch: Partial<Task> = { status: 'review' };
if (resolvedReviewer && resolvedReviewer !== task.assignedTo) { if (resolvedReviewer && resolvedReviewer !== task.assignedTo) {
@ -135,11 +195,19 @@ export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
} }
export function cancelTask(cwd: string, id: string): Task { export function cancelTask(cwd: string, id: string): Task {
const { task } = getTask(cwd, id);
if (task.status === 'done' || task.status === 'cancelled') {
throw new Error(`Task ${id} is terminal (${task.status}) and cannot transition to cancelled`);
}
return updateTask(cwd, id, { status: 'cancelled' }); return updateTask(cwd, id, { status: 'cancelled' });
} }
export function reopenTask(cwd: string, id: string): Task { export function reopenTask(cwd: string, id: string): Task {
return updateTask(cwd, id, { status: 'open' }); const { task } = getTask(cwd, id);
if (task.status !== 'review') {
throw new Error(`Task ${id} cannot transition ${task.status} → open; expected review → open`);
}
return updateTask(cwd, id, { status: 'open', claimedBy: undefined });
} }
/** /**
@ -167,7 +235,12 @@ export function deleteTask(cwd: string, id: string): { id: string } {
* a specific agent and have a waiting daemon pick it up. * a specific agent and have a waiting daemon pick it up.
*/ */
export function assignTask(cwd: string, id: string, agentName: string): Task { export function assignTask(cwd: string, id: string, agentName: string): Task {
return updateTask(cwd, id, { assignedTo: agentName }); const resolved = resolveAgentName(cwd, agentName);
const { task } = getTask(cwd, id);
return updateTask(cwd, id, {
assignedTo: resolved,
claimedBy: task.status === 'in_progress' ? resolved : undefined,
});
} }
function getPreferredReviewer(cwd: string): string | undefined { function getPreferredReviewer(cwd: string): string | undefined {
@ -178,7 +251,7 @@ function getPreferredReviewer(cwd: string): string | undefined {
} }
} }
function toIndexEntry(task: Task, filePath: string) { export function toIndexEntry(task: Task, filePath: string) {
return { return {
id: task.id, id: task.id,
type: 'task', type: 'task',

View File

@ -27,6 +27,21 @@ import { getStatus } from '../core/services/statusService.js';
import { discoverServer as discoverHubServer, resolveReachableServerUrl } from '../discovery.js'; import { discoverServer as discoverHubServer, resolveReachableServerUrl } from '../discovery.js';
import { VERSION } from '../version.js'; import { VERSION } from '../version.js';
export const DEFAULT_WORK_TIMEOUT_SEC = 50;
export const DEFAULT_TASK_LIST_LIMIT = 50;
export function boundedTaskList<T extends { updatedAt?: string; createdAt?: string }>(
tasks: T[],
limit: number = DEFAULT_TASK_LIST_LIMIT,
): { tasks: T[]; total: number; returned: number; omitted: number } {
const safeLimit = Math.max(1, Math.min(200, Math.floor(limit)));
const sorted = [...tasks].sort((a, b) =>
String(b.updatedAt ?? b.createdAt ?? '').localeCompare(String(a.updatedAt ?? a.createdAt ?? '')),
);
const selected = sorted.slice(0, safeLimit);
return { tasks: selected, total: tasks.length, returned: selected.length, omitted: tasks.length - selected.length };
}
/** /**
* AgentHub MCP server (TSK-0030). * AgentHub MCP server (TSK-0030).
* *
@ -194,10 +209,16 @@ export async function startMcpServer(cwd: string): Promise<void> {
}); });
server.tool('agenthub_work', server.tool('agenthub_work',
'Block until there is work for you, then return it. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.', 'Block until there is work for you, then return it. The default 50s timeout stays below known MCP client limits; SSE still wakes immediately, so this only causes more empty wake-ups and does not add delivery latency. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.',
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional(), unattended: z.boolean().optional() }, { agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional(), unattended: z.boolean().optional() },
async ({ agent, role, timeoutSec, unattended }) => { async ({ agent, role, timeoutSec, unattended }) => {
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' }; const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
if (remote) await remoteClient.setLoop(serverUrl!, agent, true);
const leave = async (reason: string) => {
if (remote) {
try { await remoteClient.setLoop(ctx.serverUrl!, agent, false, reason); } catch { /* best-effort */ }
}
};
const reviewer = isReviewerRole(ctx.role); const reviewer = isReviewerRole(ctx.role);
const useServerUrl = (nextServerUrl?: string) => { const useServerUrl = (nextServerUrl?: string) => {
if (nextServerUrl) ctx.serverUrl = nextServerUrl; if (nextServerUrl) ctx.serverUrl = nextServerUrl;
@ -271,20 +292,37 @@ export async function startMcpServer(cwd: string): Promise<void> {
'call agenthub_ask (it routes to the architect) and await the answer instead of stalling.'; 'call agenthub_ask (it routes to the architect) and await the answer instead of stalling.';
} }
const immediate = await finder(); const immediate = await finder();
if (immediate) return asText({ ...immediate, loop: LOOP }); if (immediate) {
await leave('work delivered');
return asText({ ...immediate, loop: LOOP });
}
const emptyMsg = reviewer const emptyMsg = reviewer
? `No task in review for ${agent}, and no hub server to wait on.` ? `No task in review for ${agent}, and no hub server to wait on.`
: `No open task addressed to ${agent}, and no hub server to wait on.`; : `No open task addressed to ${agent}, and no hub server to wait on.`;
if (!remote) return asText(emptyMsg); if (!remote) return asText(emptyMsg);
const result = await waitForTask(serverUrl!, finder, timeoutSec ?? 300); const effectiveTimeout = timeoutSec ?? DEFAULT_WORK_TIMEOUT_SEC;
if (result) return asText({ ...result, loop: LOOP }); let result;
try {
result = await waitForTask(serverUrl!, finder, effectiveTimeout);
} catch (err) {
await leave(`error: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
if (result) {
await leave('work delivered');
return asText({ ...result, loop: LOOP });
}
await leave('timeout');
const kind = reviewer ? 'review submission' : 'task or message'; const kind = reviewer ? 'review submission' : 'task or message';
return asText(`No ${kind} for ${agent} within ${timeoutSec ?? 300}s. ${LOOP}`); return asText(`No ${kind} for ${agent} within ${effectiveTimeout}s. ${LOOP}`);
}); });
server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.', server.tool('agenthub_task_list', 'List tasks newest-first, optionally filtered by status and/or role. Defaults to 50 and reports total/omitted counts.',
{ status: z.string().optional(), role: z.string().optional() }, { status: z.string().optional(), role: z.string().optional(), limit: z.number().int().positive().max(200).optional() },
async ({ status, role }) => asText(remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role }))); async ({ status, role, limit }) => {
const tasks = remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, role });
return asText(boundedTaskList(tasks, limit));
});
server.tool('agenthub_task_show', 'Show one task with its full body.', server.tool('agenthub_task_show', 'Show one task with its full body.',
{ id: z.string() }, { id: z.string() },
@ -308,7 +346,48 @@ export async function startMcpServer(cwd: string): Promise<void> {
server.tool('agenthub_task_review', 'Submit a finished task for architect review. Implementers use THIS, never agenthub_task_done.', server.tool('agenthub_task_review', 'Submit a finished task for architect review. Implementers use THIS, never agenthub_task_done.',
{ id: z.string() }, { id: z.string() },
async ({ id }) => asText(remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id))); async ({ id }) => {
const submitted = remote ? await remoteClient.reviewTask(serverUrl!, id) : reviewTask(root, id);
if (!remote) {
return asText({ submitted, note: 'Submitted. Relaunch agenthub_work to stay reachable.' });
}
const agent = submitted.claimedBy ?? submitted.assignedTo;
if (!agent) return asText({ submitted, note: 'Submitted, but no implementer identity is available for continued waiting.' });
const ctx: AgentContext = {
serverUrl,
projectCwd: root,
agent,
role: submitted.role ?? 'implementer',
};
await remoteClient.setLoop(serverUrl!, agent, true);
const finder = async (nextServerUrl?: string) => {
if (nextServerUrl) ctx.serverUrl = nextServerUrl;
const found = await findAddressedOpenTask(ctx);
if (found) {
await remoteClient.claimTask(ctx.serverUrl!, found.task.id, agent);
const detail = await remoteClient.getTask(ctx.serverUrl!, found.task.id);
return { claimed: found.task, body: detail.body };
}
const messages = await remoteClient.getInbox(ctx.serverUrl!, agent, true);
return messages.length ? { claimed: null, messages } : null;
};
try {
const next = await waitForTask(serverUrl!, finder, DEFAULT_WORK_TIMEOUT_SEC);
await remoteClient.setLoop(ctx.serverUrl!, agent, false, next ? 'next work delivered' : 'timeout');
return asText({
submitted,
next,
note: next
? 'Review submitted and the implementer stayed reachable; next work/message is included.'
: `Review submitted; no next work arrived within ${DEFAULT_WORK_TIMEOUT_SEC}s.`,
});
} catch (err) {
try {
await remoteClient.setLoop(ctx.serverUrl!, agent, false, `error: ${err instanceof Error ? err.message : String(err)}`);
} catch { /* best-effort */ }
throw err;
}
});
server.tool('agenthub_task_reopen', 'Re-trigger a task after review (architect: send back to the implementer).', server.tool('agenthub_task_reopen', 'Re-trigger a task after review (architect: send back to the implementer).',
{ id: z.string() }, { id: z.string() },

View File

@ -54,7 +54,11 @@ function renderAgentCardsSSR(agents: AgentHealth[]): string {
<span class="ac-role">${escapeHtml(a.role)}</span> <span class="ac-role">${escapeHtml(a.role)}</span>
<span class="light light-${a.state}" data-light>${a.state}</span> <span class="light light-${a.state}" data-light>${a.state}</span>
</header> </header>
<div class="ac-meta" data-meta></div> <div class="ac-meta" data-meta>${a.inLoop
? `in loop since ${escapeHtml(a.loopSince ?? 'now')}`
: a.loopExitReason
? `out of loop · ${escapeHtml(a.loopExitReason)}`
: 'loop state unknown'}</div>
<div class="ac-test"> <div class="ac-test">
<input class="ac-input" type="text" value="health-check ping" maxlength="200" aria-label="Test message" /> <input class="ac-input" type="text" value="health-check ping" maxlength="200" aria-label="Test message" />
<button class="ac-send" type="button">Send test</button> <button class="ac-send" type="button">Send test</button>
@ -250,7 +254,12 @@ export function renderAgentHealthHtml(cwd: string, startedAtMs: number): string
light.textContent = a.state; light.textContent = a.state;
var seen = a.lastSeenAgoSec != null ? 'last seen ' + compact(a.lastSeenAgoSec) + ' ago' : 'never seen'; var seen = a.lastSeenAgoSec != null ? 'last seen ' + compact(a.lastSeenAgoSec) + ' ago' : 'never seen';
var task = a.taskId ? ' · <a href="/tasks/' + esc(a.taskId) + '">' + esc(a.taskId) + '</a>' : ''; var task = a.taskId ? ' · <a href="/tasks/' + esc(a.taskId) + '">' + esc(a.taskId) + '</a>' : '';
card.querySelector('[data-meta]').innerHTML = esc(seen) + task; var loop = a.inLoop
? ' · in loop for ' + compact(a.loopSinceAgoSec || 0)
: a.loopExitReason
? ' · out of loop ' + compact(a.loopExitAgoSec || 0) + ' ago (' + esc(a.loopExitReason) + ')'
: ' · loop unknown';
card.querySelector('[data-meta]').innerHTML = esc(seen) + task + loop;
var tr = card.querySelector('[data-track]'); var tr = card.querySelector('[data-track]');
var newTrack = trackStatus(a.name); var newTrack = trackStatus(a.name);
if (tr.innerHTML !== newTrack) tr.innerHTML = newTrack; if (tr.innerHTML !== newTrack) tr.innerHTML = newTrack;

View File

@ -58,6 +58,14 @@ export function modalsHtml(): string {
<option value="critical">critical</option> <option value="critical">critical</option>
</select> </select>
</label> </label>
<label class="modal-field modal-field-inline">
<span class="modal-label">Already completed outside AgentHub</span>
<input id="tmExternal" type="checkbox" />
</label>
<label class="modal-field" id="tmExternalByWrap" hidden>
<span class="modal-label">Completed by</span>
<input id="tmExternalBy" type="text" placeholder="agent name" />
</label>
<p class="modal-note">Created unassigned the architect picks it up and delegates it.</p> <p class="modal-note">Created unassigned the architect picks it up and delegates it.</p>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button> <button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
@ -622,6 +630,7 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var data = null; var data = null;
try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {} try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {}
if (type === 'task') { refresh(); feedFromEvent(data); } if (type === 'task') { refresh(); feedFromEvent(data); }
if (type === 'agent' && window.refreshAgentHealth) window.refreshAgentHealth();
refreshBudget(); refreshBudget();
}; };
source.onerror = function() { source.onerror = function() {
@ -683,6 +692,11 @@ export function columnsJs(opts: ColumnsJsOpts): string {
if (!res.ok) throw new Error('create failed (' + res.status + ')'); if (!res.ok) throw new Error('create failed (' + res.status + ')');
return res.json(); return res.json();
} }
async function recordTask(body) {
var res = await fetch('/tasks/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error('record failed (' + res.status + ')');
return res.json();
}
async function patchTask(id, body) { async function patchTask(id, body) {
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) { if (!res.ok) {
@ -692,6 +706,13 @@ export function columnsJs(opts: ColumnsJsOpts): string {
} }
return res.json(); return res.json();
} }
async function dispatchTask(id, agent) {
var res = await fetch('/tasks/' + encodeURIComponent(id) + '/dispatch', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent: agent })
});
if (!res.ok) throw new Error('dispatch failed (' + res.status + ')');
return res.json();
}
function flashCard(id) { function flashCard(id) {
var el = document.querySelector('.card[data-id="' + id + '"]'); var el = document.querySelector('.card[data-id="' + id + '"]');
if (!el) return; if (!el) return;
@ -720,8 +741,14 @@ export function columnsJs(opts: ColumnsJsOpts): string {
// in the title, else mark it as a manual board claim. // in the title, else mark it as a manual board claim.
var titledAgent = assigned ? '' : agentFromTitle(id); var titledAgent = assigned ? '' : agentFromTitle(id);
var agent = assigned || titledAgent || 'manual'; var agent = assigned || titledAgent || 'manual';
var rosterAgent = (AGENTS || []).find(function(a) { return a.name === agent; });
if (rosterAgent && rosterAgent.dispatch === 'architect') {
await dispatchTask(id, agent);
toast(id + ' \\u2192 architect started @' + agent);
} else {
await patchTask(id, { status: 'in_progress', assignedTo: agent }); await patchTask(id, { status: 'in_progress', assignedTo: agent });
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : '')); 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 : ''));
@ -873,12 +900,25 @@ export function columnsJs(opts: ColumnsJsOpts): string {
if (e.target.closest && e.target.closest('.console-overlay')) e.preventDefault(); if (e.target.closest && e.target.closest('.console-overlay')) e.preventDefault();
}); });
// Live-console toggle lives inside the card <a> — stop the navigation. // Live-console toggle lives inside the card <a> — stop the navigation.
// Only ONE console overlay at a time: opening one closes any other.
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var tog = e.target.closest && e.target.closest('[data-console-toggle]'); var tog = e.target.closest && e.target.closest('[data-console-toggle]');
if (!tog) return; if (!tog) return;
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
var id = tog.getAttribute('data-console-toggle'); var id = tog.getAttribute('data-console-toggle');
var willOpen = !openConsoles[id]; var willOpen = !openConsoles[id];
if (willOpen) {
Object.keys(openConsoles).forEach(function(otherId) {
if (otherId === id || !openConsoles[otherId]) return;
openConsoles[otherId] = false;
var ot = document.querySelector('[data-console-toggle="' + otherId + '"]');
if (ot) {
ot.setAttribute('aria-expanded', 'false');
var oc = ot.querySelector('.cc-chevron'); if (oc) oc.textContent = '\\u25b8';
}
closeConsole(otherId);
});
}
openConsoles[id] = willOpen; openConsoles[id] = willOpen;
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8'; var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
@ -934,6 +974,13 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var form = document.getElementById('tmForm'); var form = document.getElementById('tmForm');
if (!btn || !modal || !form) return; if (!btn || !modal || !form) return;
var titleInput = document.getElementById('tmTitleInput'); var titleInput = document.getElementById('tmTitleInput');
var externalInput = document.getElementById('tmExternal');
var externalBy = document.getElementById('tmExternalBy');
var externalByWrap = document.getElementById('tmExternalByWrap');
externalInput.addEventListener('change', function() {
externalByWrap.hidden = !externalInput.checked;
externalBy.required = externalInput.checked;
});
function openModal() { function openModal() {
modal.hidden = false; modal.hidden = false;
btn.setAttribute('aria-expanded', 'true'); btn.setAttribute('aria-expanded', 'true');
@ -957,8 +1004,10 @@ export function columnsJs(opts: ColumnsJsOpts): string {
var priority = document.getElementById('tmPriority').value || undefined; var priority = document.getElementById('tmPriority').value || undefined;
try { try {
// No assignee/role: the task lands open in the architect's lap to route. // No assignee/role: the task lands open in the architect's lap to route.
var task = await postTask({ title: title, description: description, priority: priority }); var task = externalInput.checked
toast(task.id + ' created'); ? await recordTask({ title: title, description: description, priority: priority, doneBy: (externalBy.value || '').trim() })
: await postTask({ title: title, description: description, priority: priority });
toast(task.id + (externalInput.checked ? ' recorded as external work' : ' created'));
closeModal(); closeModal();
await refresh(); await refreshBudget(); flashCard(task.id); await refresh(); await refreshBudget(); flashCard(task.id);
} catch (err) { toast(err.message || 'create failed', { error: true }); } } catch (err) { toast(err.message || 'create failed', { error: true }); }

View File

@ -74,9 +74,15 @@ window.__b2RenderAgents = function (report) {
var agents = report && report.agents ? report.agents : []; var agents = report && report.agents ? report.agents : [];
if (!agents.length) { box.innerHTML = '<div class="budget-empty">no agents yet</div>'; return; } if (!agents.length) { box.innerHTML = '<div class="budget-empty">no agents yet</div>'; return; }
box.innerHTML = agents.map(function (a) { box.innerHTML = agents.map(function (a) {
var detail = a.state === 'busy' && a.taskId ? 'busy · ' + a.taskId var loop = a.dispatch === 'architect'
: a.state === 'stale' && a.taskId ? 'stale · ' + a.taskId + ' open' ? (a.taskId ? 'architect started' : a.waitingTaskId ? 'wartet auf Architekten-Start' : 'architect dispatch')
: a.state; : a.inLoop
? 'loop ' + compact(a.loopSinceAgoSec || 0)
: a.loopExitReason
? 'out ' + compact(a.loopExitAgoSec || 0) + ' · ' + a.loopExitReason
: 'loop ?';
var work = a.taskId ? a.taskId : a.waitingTaskId ? a.waitingTaskId + ' waiting' : '';
var detail = loop + (work ? ' · ' + work : '');
return '<div class="b2-agent-row" title="' + esc(a.name) + ': ' + esc(detail) + '">' + return '<div class="b2-agent-row" title="' + esc(a.name) + ': ' + esc(detail) + '">' +
'<span class="b2-light st-' + esc(a.state) + '" aria-hidden="true"></span>' + '<span class="b2-light st-' + esc(a.state) + '" aria-hidden="true"></span>' +
'<span class="b2-agent-name">' + esc(a.name) + '</span>' + '<span class="b2-agent-name">' + esc(a.name) + '</span>' +
@ -86,6 +92,7 @@ window.__b2RenderAgents = function (report) {
async function refreshAgentHealth() { async function refreshAgentHealth() {
try { window.__b2RenderAgents(await getJSON('/health')); } catch (_) {} try { window.__b2RenderAgents(await getJSON('/health')); } catch (_) {}
} }
window.refreshAgentHealth = refreshAgentHealth;
refreshAgentHealth(); refreshAgentHealth();
setInterval(refreshAgentHealth, 5000); setInterval(refreshAgentHealth, 5000);
${v1BudgetJs}`; ${v1BudgetJs}`;

View File

@ -52,6 +52,20 @@ export function listHubEventsAfter(cwd: string, afterSeq: number, limit = 1000):
return events; return events;
} }
export function countHubEventsAfter(cwd: string, afterSeq: number): number {
const row = dbFor(cwd)
.prepare('SELECT COUNT(*) AS count FROM hub_events WHERE seq > ?')
.get(afterSeq) as { count: number };
return row.count;
}
export function latestHubEventSeq(cwd: string): number {
const row = dbFor(cwd)
.prepare('SELECT COALESCE(MAX(seq), 0) AS seq FROM hub_events')
.get() as { seq: number };
return row.seq;
}
export function closeHubEventLogForTests(): void { export function closeHubEventLogForTests(): void {
cachedDb?.close(); cachedDb?.close();
cachedDb = undefined; cachedDb = undefined;

View File

@ -4,6 +4,9 @@ import { readEntity } from '../core/files.js';
import { getEntityDir, type EntityType } from '../core/paths.js'; import { getEntityDir, type EntityType } from '../core/paths.js';
import { emitChange, seenRecently, signatureOf } from './events.js'; import { emitChange, seenRecently, signatureOf } from './events.js';
import type { AgentHubEvent, AgentHubEventType } from './events.js'; import type { AgentHubEvent, AgentHubEventType } from './events.js';
import { TaskSchema } from '../core/schema.js';
import { toIndexEntry } from '../core/services/taskService.js';
import { Index } from '../core/index.js';
/** /**
* Filesystem-watch layer. * Filesystem-watch layer.
@ -32,6 +35,17 @@ const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
// fs.watch can fire several events (rename + change) for a single write, and a // fs.watch can fire several events (rename + change) for a single write, and a
// file may be observed mid-write. Coalesce per-path bursts before reading. // file may be observed mid-write. Coalesce per-path bursts before reading.
const DEBOUNCE_MS = 40; const DEBOUNCE_MS = 40;
const RETRY_MS = 100;
const MAX_READ_ATTEMPTS = 3;
const indexErrors = new Map<string, { filePath: string; error: string; at: string }>();
export function getFsWatchErrors(): Array<{ filePath: string; error: string; at: string }> {
return [...indexErrors.values()];
}
export function resetFsWatchErrors(): void {
indexErrors.clear();
}
function str(value: unknown): string | undefined { function str(value: unknown): string | undefined {
return value === undefined || value === null ? undefined : String(value); return value === undefined || value === null ? undefined : String(value);
@ -104,13 +118,15 @@ export function startEntityWatcher(cwd: string): () => void {
const key = `${type}:${name}`; const key = `${type}:${name}`;
const existing = timers.get(key); const existing = timers.get(key);
if (existing) clearTimeout(existing); if (existing) clearTimeout(existing);
timers.set( const filePath = getEntityDir(cwd, dir) + '/' + name;
key, const schedule = (attempt: number, delay: number) => {
setTimeout(() => { timers.set(key, setTimeout(() => {
timers.delete(key); timers.delete(key);
processFile(getEntityDir(cwd, dir) + '/' + name, type); const error = processFile(cwd, filePath, type);
}, DEBOUNCE_MS), if (error && attempt < MAX_READ_ATTEMPTS) schedule(attempt + 1, RETRY_MS);
); }, delay));
};
schedule(1, DEBOUNCE_MS);
}); });
watchers.push(watcher); watchers.push(watcher);
} catch { } catch {
@ -126,13 +142,26 @@ export function startEntityWatcher(cwd: string): () => void {
}; };
} }
function processFile(filePath: string, type: AgentHubEventType): void { function processFile(cwd: string, filePath: string, type: AgentHubEventType): Error | undefined {
let fm: Record<string, unknown>; let fm: Record<string, unknown>;
try { try {
fm = readEntity(filePath).frontmatter; const entity = readEntity(filePath);
} catch { fm = entity.frontmatter;
// Deleted again, or read mid-write -> skip; a later settled write re-fires. if (type === 'task') {
return; const task = TaskSchema.parse(fm);
const index = new Index(cwd);
try {
index.upsert(toIndexEntry(task, filePath));
} finally {
index.close();
}
}
indexErrors.delete(filePath);
} catch (cause) {
const error = cause instanceof Error ? cause : new Error(String(cause));
indexErrors.set(filePath, { filePath, error: error.message, at: new Date().toISOString() });
console.error(`AgentHub fsWatch: could not reindex ${filePath}: ${error.message}`);
return error;
} }
const built = toEvent(type, fm); const built = toEvent(type, fm);

View File

@ -1,5 +1,5 @@
import { FastifyInstance, FastifyReply } from 'fastify'; import { FastifyInstance, FastifyReply } from 'fastify';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js'; import { createTask, recordExternalTask, dispatchTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js';
import { getTaskActivity } from '../core/services/activityService.js'; import { getTaskActivity } from '../core/services/activityService.js';
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js'; import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
@ -11,8 +11,9 @@ import { getStatus, updateStatus } from '../core/services/statusService.js';
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
import { computeBudget } from '../core/services/budgetService.js'; import { computeBudget } from '../core/services/budgetService.js';
import { getRoster } from '../core/services/rosterService.js'; import { getRoster } from '../core/services/rosterService.js';
import { computeHealth, stampSeen } from '../core/services/presenceService.js'; import { computeHealth, enterLoop, leaveLoop, stampSeen } from '../core/services/presenceService.js';
import { loadConfig, saveConfig } from '../core/config.js'; import { loadConfig, saveConfig } from '../core/config.js';
import { agentIdentity } from '../core/services/identityService.js';
import { renderActivityHtml } from './activity.js'; import { renderActivityHtml } from './activity.js';
import { renderAgentHealthHtml } from './agentHealth.js'; import { renderAgentHealthHtml } from './agentHealth.js';
import { renderBoardHtml } from './board/index.js'; import { renderBoardHtml } from './board/index.js';
@ -23,11 +24,12 @@ import { renderMessagesHtml } from './messages.js';
import { renderTaskDetailHtml } from './taskDetail.js'; import { renderTaskDetailHtml } from './taskDetail.js';
import { configureDurableEvents, eventBus, emitChange } from './events.js'; import { configureDurableEvents, eventBus, emitChange } from './events.js';
import type { AgentHubEvent } from './events.js'; import type { AgentHubEvent } from './events.js';
import { listHubEventsAfter } from './eventLog.js'; import { countHubEventsAfter, latestHubEventSeq, listHubEventsAfter } from './eventLog.js';
import type { Task, Handoff, Decision, Memory, Message, Ask } from '../core/schema.js'; import type { Task, Handoff, Decision, Memory, Message, Ask } from '../core/schema.js';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { join, extname, normalize } from 'node:path'; import { join, extname, normalize } from 'node:path';
import { getFsWatchErrors } from './fsWatch.js';
const SSE_KEEPALIVE_MS = 10_000; const SSE_KEEPALIVE_MS = 10_000;
@ -61,7 +63,65 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
}); });
// Hub health: status + version + uptime + compact counts + per-agent lights. // Hub health: status + version + uptime + compact counts + per-agent lights.
app.get('/health', async () => computeHealth(cwd, startedAtMs)); app.get('/health', async () => ({
...computeHealth(cwd, startedAtMs),
indexErrors: getFsWatchErrors(),
}));
app.get('/architect/pulse', async (request) => {
const query = request.query as { sinceSeq?: string; since?: string };
const rawSince = query.sinceSeq ?? query.since;
const explicitSince = rawSince !== undefined;
const sinceSeq = Math.max(0, Number.parseInt(rawSince ?? '0', 10) || 0);
const config = loadConfig(cwd);
const architect = config.roles.architect?.preferredAgent ?? 'claude';
const health = computeHealth(cwd, startedAtMs);
const eventLimit = 5;
const eventBase = explicitSince ? sinceSeq : Math.max(0, latestHubEventSeq(cwd) - eventLimit);
const rawEvents = listHubEventsAfter(cwd, eventBase, eventLimit);
const events = rawEvents.map(({ seq, type, action, id, status, assignedTo }) => ({
seq, type, action, id, status, assignedTo,
}));
const nextSeq = rawEvents.length ? rawEvents[rawEvents.length - 1].seq : latestHubEventSeq(cwd);
const availableEvents = countHubEventsAfter(cwd, explicitSince ? sinceSeq : 0);
const eventsOmitted = Math.max(0, availableEvents - rawEvents.length);
const allMessages = listMessages(cwd)
.filter((message) => message.to === architect && message.status === 'unread');
const messageLimit = 5;
return {
at: new Date().toISOString(),
nextSeq,
reviews: listTasks(cwd, { status: 'review' }).map((task) => ({
id: task.id,
assignedTo: task.assignedTo,
updatedAt: task.updatedAt,
})),
dormantAgents: health.agents
.filter((agent) => !agent.inLoop && (agent.taskId || agent.loopExitReason))
.map((agent) => ({
name: agent.name,
taskId: agent.taskId,
lastSeenAgoSec: agent.lastSeenAgoSec,
loopExitAgoSec: agent.loopExitAgoSec,
loopExitReason: agent.loopExitReason,
})),
messages: allMessages.slice(0, messageLimit).map(({ id, from, taskId, createdAt }) => ({
id, from, taskId, createdAt,
})),
events,
omitted: {
messages: Math.max(0, allMessages.length - messageLimit),
events: eventsOmitted,
},
};
});
app.get('/agents/:agent/identity', async (request, reply) => {
try {
const identity = agentIdentity(cwd, (request.params as { agent: string }).agent);
return { canonical: identity.canonical, names: [...identity.names] };
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : String(err));
}
});
// Agent health-check page (TSK-0230): reachability traffic light per agent // Agent health-check page (TSK-0230): reachability traffic light per agent
// (same /health data), test-message send with live delivery tracking, // (same /health data), test-message send with live delivery tracking,
@ -230,6 +290,24 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
eventBus.publish(ev); eventBus.publish(ev);
return { ok: true, agent, action: ev.action }; return { ok: true, agent, action: ev.action };
}); });
app.post('/agents/:agent/loop', async (request, reply) => {
const agent = (request.params as { agent: string }).agent;
const { active, reason } = request.body as { active?: boolean; reason?: string };
try {
const canonical = agentIdentity(cwd, agent).canonical;
if (active) enterLoop(canonical);
else leaveLoop(canonical, reason ?? 'ended');
eventBus.publish({
type: 'agent',
action: active ? 'joined' : 'left',
id: canonical,
status: active ? 'in_loop' : `out_of_loop:${reason ?? 'ended'}`,
});
return { ok: true, agent: canonical, active: !!active, reason };
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : String(err));
}
});
// ─── Status ────────────────────────────────────────────────────────────── // ─── Status ──────────────────────────────────────────────────────────────
app.get('/status', async () => ({ body: getStatus(cwd) })); app.get('/status', async () => ({ body: getStatus(cwd) }));
@ -296,6 +374,16 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
); );
return task; return task;
}); });
app.post('/tasks/record', async (request, reply) => {
try {
const task = recordExternalTask(cwd, request.body as Parameters<typeof recordExternalTask>[1]);
logTaskStatus(task.id, `Nachgetragen: außerhalb von AgentHub erledigt durch ${task.doneBy}`, task.doneBy);
emitChange({ type: 'task', action: 'created', id: task.id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, claimedBy: task.claimedBy }, task.updatedAt);
return task;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Could not record external work');
}
});
app.delete('/tasks/:id', async (request, reply) => { app.delete('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
@ -351,6 +439,18 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
eventBus.publishLog({ taskId: id, ...entry }); eventBus.publishLog({ taskId: id, ...entry });
return entry; return entry;
}); });
app.post('/tasks/:id/dispatch', async (request, reply) => {
const { id } = request.params as { id: string };
const { agent } = request.body as { agent?: string };
try {
const task = dispatchTask(cwd, id, agent ?? '');
logTaskStatus(id, `Architect started ${task.claimedBy}`, 'architect');
emitChange({ type: 'task', action: 'updated', id, title: task.title, status: task.status, role: task.role, assignedTo: task.assignedTo, claimedBy: task.claimedBy }, task.updatedAt);
return task;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Dispatch failed');
}
});
app.patch('/tasks/:id', async (request, reply) => { app.patch('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
@ -359,7 +459,12 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// Assign without claiming: address an open task to an agent (no status // Assign without claiming: address an open task to an agent (no status
// change). Fires task/updated so a waiting `agenthub work` auto-claims it. // change). Fires task/updated so a waiting `agenthub work` auto-claims it.
if (patch.assignedTo !== undefined && patch.status === undefined) { if (patch.assignedTo !== undefined && patch.status === undefined) {
const assigned = assignTask(cwd, id, patch.assignedTo); let assigned: Task;
try {
assigned = assignTask(cwd, id, patch.assignedTo);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid assignee');
}
logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo); logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo);
emitChange( emitChange(
{ {
@ -379,6 +484,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
} }
let task: Task; let task: Task;
try {
switch (patch.status) { switch (patch.status) {
case 'in_progress': case 'in_progress':
{ {
@ -387,11 +493,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
// claimTask is race-guarded (open-only). Surface a lost race / non-open // claimTask is race-guarded (open-only). Surface a lost race / non-open
// claim as a clean 400 so the board drag reverts gracefully instead of // claim as a clean 400 so the board drag reverts gracefully instead of
// 500-ing, and a second agent can't clobber the first's claim. // 500-ing, and a second agent can't clobber the first's claim.
try {
task = claimTask(cwd, id, agent); task = claimTask(cwd, id, agent);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task');
}
stampSeen(agent); stampSeen(agent);
} }
break; break;
@ -417,6 +519,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
default: default:
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled'); return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
} }
} catch (err) {
const message = err instanceof Error ? err.message : 'Invalid task transition';
logTaskStatus(id, `Rejected transition${patch.status ? `${patch.status}` : ''}: ${message}`, patch.assignedTo);
return badRequest(reply, message);
}
// Uniformly log the transition for every status-changing caller (claim / // Uniformly log the transition for every status-changing caller (claim /
// review / done / cancel / reopen), so the live console tracks progress. // review / done / cancel / reopen), so the live console tracks progress.

View File

@ -4,6 +4,8 @@ import { listTasks, getTask } from '../core/services/taskService.js';
import { readTaskLog, appendTaskLog } from '../core/services/taskLogService.js'; import { readTaskLog, appendTaskLog } from '../core/services/taskLogService.js';
import { createMessage } from '../core/services/messageService.js'; import { createMessage } from '../core/services/messageService.js';
import { emitChange, eventBus } from './events.js'; import { emitChange, eventBus } from './events.js';
import { agentLoopStatus } from '../core/services/presenceService.js';
import { resolveAgentName } from '../core/services/identityService.js';
/** /**
* Server-side stuck-task watchdog (TSK-0226). * Server-side stuck-task watchdog (TSK-0226).
@ -75,9 +77,40 @@ export function runWatchdogScan(cwd: string, now: number = Date.now()): number {
const threshold = thresholdFor(priority, cfg); const threshold = thresholdFor(priority, cfg);
const waitingMs = now - Date.parse(t.updatedAt || t.createdAt); const waitingMs = now - Date.parse(t.updatedAt || t.createdAt);
const key = `${t.id}:unclaimed`; const key = `${t.id}:unclaimed`;
if (waitingMs >= threshold && cooledDown(key, threshold, now)) { let assignee = t.assignedTo;
try { assignee = resolveAgentName(cwd, t.assignedTo); } catch { /* invalid legacy target is reported below */ }
const dispatch = config.agents?.[assignee]?.dispatch ?? 'loop';
const shouldAlert = dispatch === 'architect' ? !lastAlertAt.has(key) : cooledDown(key, threshold, now);
if (waitingMs >= threshold && shouldAlert) {
lastAlertAt.set(key, now); lastAlertAt.set(key, now);
alerts++; alerts++;
if (dispatch === 'architect') {
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `${t.id} wartet auf ${assignee} — der wird von dir gestartet, nicht von selbst.`,
taskId: t.id,
});
} catch { /* best-effort */ }
logAlert(cwd, t.id, `Watchdog: wartet auf Architekten-Start von ${assignee}`);
continue;
}
if (agentLoopStatus(assignee) === 'inactive') {
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `Watchdog: ${t.id} is assigned to ${assignee}, but that agent is not in agenthub_work — manual/session wake may be required.`,
taskId: t.id,
});
} catch {
/* best-effort */
}
logAlert(cwd, t.id, `Watchdog: ${assignee} is out of the work loop — alerted ${architect}`);
}
// 1. Re-emit the task event so SSE subscribers / work loops re-wake. // 1. Re-emit the task event so SSE subscribers / work loops re-wake.
emitChange( emitChange(
{ {
@ -133,6 +166,27 @@ export function runWatchdogScan(cwd: string, now: number = Date.now()): number {
logAlert(cwd, t.id, `Watchdog: silent for ${Math.round(silentMs / 60_000)}m — alerted ${architect}`); logAlert(cwd, t.id, `Watchdog: silent for ${Math.round(silentMs / 60_000)}m — alerted ${architect}`);
} }
} }
if (t.status === 'review') {
const waitingMs = now - Date.parse(t.updatedAt || t.createdAt);
const key = `${t.id}:review`;
if (waitingMs >= cfg.staleReviewMs && cooledDown(key, cfg.staleReviewMs, now)) {
lastAlertAt.set(key, now);
alerts++;
const architect = architectName(config);
try {
createMessage(cwd, {
from: 'agenthub',
to: architect,
text: `Watchdog: ${t.id} has waited in review for ${Math.round(waitingMs / 60_000)}m — approve or reopen it.`,
taskId: t.id,
});
} catch {
/* best-effort */
}
logAlert(cwd, t.id, `Watchdog: review waiting ${Math.round(waitingMs / 60_000)}m — alerted ${architect}`);
}
}
} }
return alerts; return alerts;

View File

@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs'; import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { createTask, claimTask, doneTask } from '../src/core/services/taskService.js'; import { createTask, claimTask, reviewTask, doneTask } from '../src/core/services/taskService.js';
import { createHandoff } from '../src/core/services/handoffService.js'; import { createHandoff } from '../src/core/services/handoffService.js';
import { addMemory } from '../src/core/services/memoryService.js'; import { addMemory } from '../src/core/services/memoryService.js';
import { getTaskActivity } from '../src/core/services/activityService.js'; import { getTaskActivity } from '../src/core/services/activityService.js';
@ -41,6 +41,8 @@ describe('activityService', () => {
it('includes a status event with tokens/duration when task is done with metadata', () => { it('includes a status event with tokens/duration when task is done with metadata', () => {
const task = createTask(cwd, { title: 'Finish me', role: 'implementer' }); const task = createTask(cwd, { title: 'Finish me', role: 'implementer' });
claimTask(cwd, task.id, 'codex');
reviewTask(cwd, task.id);
doneTask(cwd, task.id, { doneBy: 'claude', doneTokens: 8500, doneDuration: 120_000 }); doneTask(cwd, task.id, { doneBy: 'claude', doneTokens: 8500, doneDuration: 120_000 });
const items = getTaskActivity(cwd, task.id); const items = getTaskActivity(cwd, task.id);
const status = items.find((i) => i.kind === 'status'); const status = items.find((i) => i.kind === 'status');

View File

@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { init } from '../src/cli/commands/init.js'; import { init } from '../src/cli/commands/init.js';
import { createTask, reviewTask, doneTask } from '../src/core/services/taskService.js'; import { createTask, claimTask, reviewTask, doneTask } from '../src/core/services/taskService.js';
import { createHandoff } from '../src/core/services/handoffService.js'; import { createHandoff } from '../src/core/services/handoffService.js';
import { createMessage } from '../src/core/services/messageService.js'; import { createMessage } from '../src/core/services/messageService.js';
import { computeBudget, TOKENS_PER_COORDINATION_ACTION } from '../src/core/services/budgetService.js'; import { computeBudget, TOKENS_PER_COORDINATION_ACTION } from '../src/core/services/budgetService.js';
@ -24,6 +24,7 @@ describe('budget: architect activity', () => {
it('counts reviews, handoffs and messages as architect activity', () => { it('counts reviews, handoffs and messages as architect activity', () => {
// Implementer-owned task; the architect only coordinates around it. // Implementer-owned task; the architect only coordinates around it.
createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' }); createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, 'TSK-0001', 'codex');
reviewTask(cwd, 'TSK-0001', 'claude'); reviewTask(cwd, 'TSK-0001', 'claude');
createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', fromAgent: 'claude', toAgent: 'codex', taskId: 'TSK-0001', summary: 'scope' }); createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', fromAgent: 'claude', toAgent: 'codex', taskId: 'TSK-0001', summary: 'scope' });
createMessage(cwd, { from: 'claude', to: 'codex', text: 'please review' }); createMessage(cwd, { from: 'claude', to: 'codex', text: 'please review' });
@ -41,6 +42,7 @@ describe('budget: architect activity', () => {
it('a done approval by someone other than the owner counts as an action', () => { it('a done approval by someone other than the owner counts as an action', () => {
createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' }); createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, 'TSK-0001', 'codex');
reviewTask(cwd, 'TSK-0001', 'claude'); reviewTask(cwd, 'TSK-0001', 'claude');
// Architect approves: doneBy = claude, owner = codex. // Architect approves: doneBy = claude, owner = codex.
doneTask(cwd, 'TSK-0001', { doneBy: 'claude', doneTokens: 8000 }); doneTask(cwd, 'TSK-0001', { doneBy: 'claude', doneTokens: 8000 });

View File

@ -13,7 +13,10 @@ interface HealthBody {
startedAt: string; startedAt: string;
uptimeSec: number; uptimeSec: number;
counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number }; counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number };
agents: Array<{ name: string; role: string; state: string; taskId?: string; lastSeen?: string; lastSeenAgoSec?: number }>; agents: Array<{
name: string; role: string; state: string; taskId?: string; lastSeen?: string; lastSeenAgoSec?: number;
inLoop: boolean; loopSince?: string; loopExitReason?: string;
}>;
} }
describe('GET /health + lastSeen stamping (TSK-0226)', () => { describe('GET /health + lastSeen stamping (TSK-0226)', () => {
@ -60,6 +63,22 @@ describe('GET /health + lastSeen stamping (TSK-0226)', () => {
expect(kimi!.lastSeenAgoSec).toBeLessThan(120); expect(kimi!.lastSeenAgoSec).toBeLessThan(120);
}); });
it('shows work-loop entry and the latest exit reason', async () => {
await app.inject({ method: 'POST', url: '/agents/codex/loop', payload: { active: true } });
let codex = (await getHealth()).agents.find((a) => a.name === 'codex')!;
expect(codex.inLoop).toBe(true);
expect(codex.loopSince).toBeDefined();
await app.inject({
method: 'POST',
url: '/agents/codex/loop',
payload: { active: false, reason: 'client timeout' },
});
codex = (await getHealth()).agents.find((a) => a.name === 'codex')!;
expect(codex.inLoop).toBe(false);
expect(codex.loopExitReason).toBe('client timeout');
});
it('stamps lastSeen on message send and counts the unread message', async () => { it('stamps lastSeen on message send and counts the unread message', async () => {
await app.inject({ method: 'POST', url: '/messages', payload: { from: 'codex', to: 'claude', text: 'ping' } }); await app.inject({ method: 'POST', url: '/messages', payload: { from: 'codex', to: 'claude', text: 'ping' } });
const h = await getHealth(); const h = await getHealth();

View File

@ -0,0 +1,37 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
import { agentIdentity, resolveAgentName } from '../src/core/services/identityService.js';
describe('agent identity resolution', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-identity-'));
init(cwd, { projectName: 'identity-test', yes: true });
const config = loadConfig(cwd);
config.agents = {
claude: { role: 'architect', aliases: ['windows-claude'] },
kimi: { role: 'implementer', aliases: ['kimi-ah'] },
};
config.roles.architect.preferredAgent = 'claude';
config.roles.implementer.preferredAgent = 'kimi';
saveConfig(cwd, config);
});
afterEach(() => rmSync(cwd, { recursive: true, force: true }));
it('maps aliases and roles to the canonical roster agent', () => {
expect(resolveAgentName(cwd, 'kimi-ah')).toBe('kimi');
expect(resolveAgentName(cwd, 'implementer')).toBe('kimi');
expect(resolveAgentName(cwd, 'architect')).toBe('claude');
expect(agentIdentity(cwd, 'kimi').names).toEqual(new Set(['kimi', 'kimi-ah', 'implementer']));
});
it('rejects an unknown recipient when a roster is configured', () => {
expect(() => resolveAgentName(cwd, 'ghost-agent')).toThrow(/unknown agent, alias, or role/i);
});
});

View File

@ -4,7 +4,12 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { init } from '../src/cli/commands/init.js'; import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js'; import { loadConfig, saveConfig } from '../src/core/config.js';
import { resolveMcpContext } from '../src/mcp/server.js'; import {
DEFAULT_TASK_LIST_LIMIT,
DEFAULT_WORK_TIMEOUT_SEC,
boundedTaskList,
resolveMcpContext,
} from '../src/mcp/server.js';
describe('MCP context resolution', () => { describe('MCP context resolution', () => {
let cwd: string; let cwd: string;
@ -35,3 +40,20 @@ describe('MCP context resolution', () => {
expect(context).toEqual({ root: cwd, serverUrl: 'http://127.0.0.1:3377' }); expect(context).toEqual({ root: cwd, serverUrl: 'http://127.0.0.1:3377' });
}); });
}); });
describe('MCP bounded defaults', () => {
it('uses a client-safe work timeout', () => {
expect(DEFAULT_WORK_TIMEOUT_SEC).toBe(50);
});
it('returns task lists newest-first with explicit truncation metadata', () => {
const tasks = Array.from({ length: 55 }, (_, i) => ({
id: `TSK-${String(i).padStart(4, '0')}`,
updatedAt: new Date(Date.UTC(2026, 0, i + 1)).toISOString(),
}));
const result = boundedTaskList(tasks);
expect(result).toMatchObject({ total: 55, returned: DEFAULT_TASK_LIST_LIMIT, omitted: 5 });
expect(result.tasks[0].id).toBe('TSK-0054');
expect(result.tasks.at(-1)?.id).toBe('TSK-0005');
});
});

View File

@ -4,6 +4,7 @@ import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { buildApp } from '../src/server/index.js'; import { buildApp } from '../src/server/index.js';
import { init } from '../src/cli/commands/init.js'; import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
describe('server routes', () => { describe('server routes', () => {
let cwd: string; let cwd: string;
@ -37,6 +38,57 @@ describe('server routes', () => {
expect(JSON.parse(res.payload)).toHaveLength(1); expect(JSON.parse(res.payload)).toHaveLength(1);
}); });
it('returns a compact architect pulse with reviews, dormant agents, messages, and event delta', async () => {
await app.inject({
method: 'POST', url: '/tasks',
payload: { title: 'Pulse review', role: 'implementer', assignedTo: 'codex' },
});
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ method: 'POST', url: '/agents/codex/loop', payload: { active: true } });
await app.inject({
method: 'POST', url: '/agents/codex/loop',
payload: { active: false, reason: 'turn ended' },
});
await app.inject({
method: 'POST', url: '/messages',
payload: { from: 'codex', to: 'architect', text: 'review ready' },
});
const res = await app.inject({ method: 'GET', url: '/architect/pulse?sinceSeq=0' });
expect(res.statusCode).toBe(200);
const pulse = JSON.parse(res.payload);
expect(pulse.reviews).toEqual([
expect.objectContaining({ id: 'TSK-0001', assignedTo: 'codex' }),
]);
expect(pulse.dormantAgents).toEqual([
expect.objectContaining({ name: 'codex', loopExitReason: 'turn ended' }),
]);
expect(pulse.messages).toEqual([
expect.objectContaining({ from: 'codex' }),
]);
expect(pulse.events.length).toBeGreaterThan(0);
expect(pulse.nextSeq).toBeGreaterThan(0);
expect(pulse.omitted).toEqual(expect.objectContaining({ messages: 0, events: expect.any(Number) }));
expect(pulse.reviews[0].title).toBeUndefined();
const delta = await app.inject({ method: 'GET', url: `/architect/pulse?since=${pulse.nextSeq}` });
const deltaPulse = JSON.parse(delta.payload);
expect(deltaPulse.events).toEqual([]);
expect(deltaPulse.nextSeq).toBe(pulse.nextSeq);
expect(delta.payload.length).toBeLessThan(1000);
});
it('returns 400 when assigning to an unknown agent', async () => {
const config = loadConfig(cwd);
config.agents = { codex: { role: 'implementer', dispatch: 'loop' } };
saveConfig(cwd, config);
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { assignedTo: 'unknown-agent' } });
expect(res.statusCode).toBe(400);
expect(res.payload).toContain('Unknown agent');
});
it('returns 404 for unknown task', async () => { it('returns 404 for unknown task', async () => {
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' }); const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' });
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
@ -64,11 +116,33 @@ describe('server routes', () => {
it('PATCH /tasks/:id → review', async () => { it('PATCH /tasks/:id → review', 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' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).status).toBe('review'); expect(JSON.parse(res.payload).status).toBe('review');
}); });
it('rejects open → review and records the failed transition in the task log', 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: 'review' } });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.payload).error).toMatch(/open.*review/i);
const log = JSON.parse((await app.inject({ method: 'GET', url: '/tasks/TSK-0001/log' })).payload).log;
expect(log.some((entry: { text: string }) => entry.text.includes('Rejected transition'))).toBe(true);
});
it('preserves claimedBy through review and done', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({
method: 'PATCH', url: '/tasks/TSK-0001',
payload: { status: 'in_progress', assignedTo: 'codex' },
});
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
const done = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
expect(done.statusCode).toBe(200);
expect(JSON.parse(done.payload)).toMatchObject({ status: 'done', claimedBy: 'codex', doneBy: 'codex' });
});
it('a second claim of the same task returns a clean 400 (race guard, board does not 500) — TSK-0007', async () => { it('a second claim of the same task returns a clean 400 (race guard, board does not 500) — TSK-0007', 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 first = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } }); const first = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } });
@ -83,6 +157,7 @@ describe('server routes', () => {
it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => { it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
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: 'review', reviewer: 'claude' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const task = JSON.parse(res.payload); const task = JSON.parse(res.payload);
@ -103,8 +178,8 @@ describe('server routes', () => {
it('PATCH /tasks/:id → open (reopen)', async () => { it('PATCH /tasks/:id → open (reopen)', 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' } });
// First cancel it, then reopen await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'cancelled' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).status).toBe('open'); expect(JSON.parse(res.payload).status).toBe('open');
@ -113,6 +188,7 @@ describe('server routes', () => {
it('review and cancelled tasks appear in GET /tasks list', async () => { it('review and cancelled tasks appear in GET /tasks list', 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' } });
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'B', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'B', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0002', payload: { status: 'cancelled' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0002', payload: { status: 'cancelled' } });
@ -203,6 +279,8 @@ describe('server routes', () => {
it('GET /tasks/:id/activity includes tokens/duration from done metadata', async () => { it('GET /tasks/:id/activity includes tokens/duration from done metadata', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: '/tasks/TSK-0001', url: '/tasks/TSK-0001',
@ -251,6 +329,16 @@ describe('server routes', () => {
decisions: ['DEC-0001'], decisions: ['DEC-0001'],
}, },
}); });
await app.inject({
method: 'PATCH',
url: '/tasks/TSK-0001',
payload: { status: 'in_progress', assignedTo: 'codex' },
});
await app.inject({
method: 'PATCH',
url: '/tasks/TSK-0001',
payload: { status: 'review' },
});
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: '/tasks/TSK-0001', url: '/tasks/TSK-0001',
@ -269,6 +357,8 @@ describe('server routes', () => {
it('serves the activity page with tasks only — messaging split out to /messages', async () => { it('serves the activity page with tasks only — messaging split out to /messages', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: '/tasks/TSK-0001', url: '/tasks/TSK-0001',

View File

@ -8,7 +8,7 @@
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync } from 'fs'; import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { buildApp } from '../src/server/index.js'; import { buildApp } from '../src/server/index.js';
@ -17,8 +17,8 @@ import { eventBus } from '../src/server/events.js';
import type { AgentHubEvent } from '../src/server/events.js'; import type { AgentHubEvent } from '../src/server/events.js';
import { parseSSEBuffer, formatEvent, watchEvents } from '../src/cli/commands/watch.js'; import { parseSSEBuffer, formatEvent, watchEvents } from '../src/cli/commands/watch.js';
import { init } from '../src/cli/commands/init.js'; import { init } from '../src/cli/commands/init.js';
import { startEntityWatcher } from '../src/server/fsWatch.js'; import { getFsWatchErrors, resetFsWatchErrors, startEntityWatcher } from '../src/server/fsWatch.js';
import { createTask, claimTask } from '../src/core/services/taskService.js'; import { createTask, claimTask, listTasks } from '../src/core/services/taskService.js';
import { listHubEventsAfter } from '../src/server/eventLog.js'; import { listHubEventsAfter } from '../src/server/eventLog.js';
// ─── 1. Unit: SSE buffer parser ────────────────────────────────────────────── // ─── 1. Unit: SSE buffer parser ──────────────────────────────────────────────
@ -187,12 +187,13 @@ describe('eventBus mutations', () => {
it('emits task/updated when PATCH /tasks/:id changes status', async () => { it('emits task/updated when PATCH /tasks/:id changes status', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
collected.length = 0; // clear the created event await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
expect(collected).toHaveLength(1); expect(collected).toHaveLength(1);
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', id: 'TSK-0001', status: 'done' }); expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', id: 'TSK-0001', status: 'done' });
expect(collected[0].seq).toBe(2); expect(collected[0].seq).toBe(4);
}); });
it('emits task/updated with assignedTo when a task is claimed', async () => { it('emits task/updated with assignedTo when a task is claimed', async () => {
@ -206,6 +207,7 @@ describe('eventBus mutations', () => {
it('emits task/updated review when an implementer submits for review', async () => { it('emits task/updated review when an implementer submits for review', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
collected.length = 0; collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
expect(collected).toHaveLength(1); expect(collected).toHaveLength(1);
@ -214,6 +216,7 @@ describe('eventBus mutations', () => {
it('emits task/updated open when the architect reopens after review', async () => { it('emits task/updated open when the architect reopens after review', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
collected.length = 0; collected.length = 0;
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
@ -560,6 +563,7 @@ describe('fsWatch emits for non-REST (CLI/file) writes', () => {
stop(); stop();
eventBus.off('change', onChange); eventBus.off('change', onChange);
collected.length = 0; collected.length = 0;
resetFsWatchErrors();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}); });
@ -586,6 +590,37 @@ describe('fsWatch emits for non-REST (CLI/file) writes', () => {
expect(ev).toBeDefined(); expect(ev).toBeDefined();
expect(ev).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'windows-claude' }); expect(ev).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'windows-claude' });
}, 4000); }, 4000);
it('reindexes every settled direct task-file edit', async () => {
const task = createTask(cwd, { title: 'manual edit', role: 'implementer' });
const file = join(cwd, '.agenthub', 'tasks', `${task.id}.md`);
await waitFor(collected, (e) => e.id === task.id);
for (const status of ['in_progress', 'review']) {
const next = readFileSync(file, 'utf-8')
.replace(/^status: .*$/m, `status: ${status}`)
.replace(/^updatedAt: .*$/m, `updatedAt: ${new Date().toISOString()}`);
writeFileSync(file, next);
const deadline = Date.now() + 2000;
while (Date.now() < deadline && listTasks(cwd).find((t) => t.id === task.id)?.status !== status) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(listTasks(cwd).find((t) => t.id === task.id)?.status).toBe(status);
}
}, 6000);
it('retains the last good index row and exposes invalid frontmatter', async () => {
const task = createTask(cwd, { title: 'invalid edit', role: 'implementer' });
const file = join(cwd, '.agenthub', 'tasks', `${task.id}.md`);
await waitFor(collected, (e) => e.id === task.id);
writeFileSync(file, readFileSync(file, 'utf-8').replace(/^status: .*$/m, 'status: definitely_invalid'));
await new Promise((resolve) => setTimeout(resolve, 500));
expect(listTasks(cwd).find((t) => t.id === task.id)?.status).toBe('open');
expect(getFsWatchErrors()).toEqual([
expect.objectContaining({ filePath: file, error: expect.stringMatching(/status/i) }),
]);
}, 4000);
}); });
// ─── 6. fsWatch + REST dedup: each change is delivered exactly once ──────────── // ─── 6. fsWatch + REST dedup: each change is delivered exactly once ────────────

View File

@ -4,10 +4,11 @@ import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { import {
createTask, listTasks, getTask, createTask, listTasks, getTask,
claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, recordExternalTask, dispatchTask,
} from '../src/core/services/taskService.js'; } from '../src/core/services/taskService.js';
import { init } from '../src/cli/commands/init.js'; import { init } from '../src/cli/commands/init.js';
import { Index } from '../src/core/index.js'; import { Index } from '../src/core/index.js';
import { loadConfig, saveConfig } from '../src/core/config.js';
describe('taskService', () => { describe('taskService', () => {
let cwd: string; let cwd: string;
@ -39,12 +40,15 @@ describe('taskService', () => {
expect(claimed.assignedTo).toBe('codex'); expect(claimed.assignedTo).toBe('codex');
expect(claimed.claimedBy).toBe('codex'); expect(claimed.claimedBy).toBe('codex');
expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' }); expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' });
reviewTask(cwd, task.id);
const done = doneTask(cwd, task.id); const done = doneTask(cwd, task.id);
expect(done.status).toBe('done'); expect(done.status).toBe('done');
expect(done.claimedBy).toBe('codex');
}); });
it('transitions a task to review', () => { it('transitions a task to review', () => {
const task = createTask(cwd, { title: 'C', role: 'implementer' }); const task = createTask(cwd, { title: 'C', role: 'implementer' });
claimTask(cwd, task.id, 'codex');
const inReview = reviewTask(cwd, task.id); const inReview = reviewTask(cwd, task.id);
expect(inReview.status).toBe('review'); expect(inReview.status).toBe('review');
// Verify index is updated // Verify index is updated
@ -55,6 +59,7 @@ describe('taskService', () => {
it('records an explicit reviewer separately from the assignee', () => { it('records an explicit reviewer separately from the assignee', () => {
const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' }); const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, task.id, 'codex');
const inReview = reviewTask(cwd, task.id, 'claude'); const inReview = reviewTask(cwd, task.id, 'claude');
expect(inReview.status).toBe('review'); expect(inReview.status).toBe('review');
expect(inReview.assignedTo).toBe('codex'); expect(inReview.assignedTo).toBe('codex');
@ -68,6 +73,7 @@ describe('taskService', () => {
it('uses the preferred reviewer when reviewTask is called without one', () => { it('uses the preferred reviewer when reviewTask is called without one', () => {
init(cwd, { projectName: 'reviewer-test', yes: true }); init(cwd, { projectName: 'reviewer-test', yes: true });
const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' }); const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' });
claimTask(cwd, task.id, 'codex');
const inReview = reviewTask(cwd, task.id); const inReview = reviewTask(cwd, task.id);
expect(inReview.reviewer).toBe('claude'); expect(inReview.reviewer).toBe('claude');
}); });
@ -81,10 +87,13 @@ describe('taskService', () => {
}); });
it('reopens a task (any status → open)', () => { it('reopens a task (any status → open)', () => {
const task = createTask(cwd, { title: 'E', role: 'implementer' }); const task = createTask(cwd, { title: 'E', role: 'implementer', assignedTo: 'kimi' });
cancelTask(cwd, task.id); claimTask(cwd, task.id, 'kimi');
reviewTask(cwd, task.id);
const reopened = reopenTask(cwd, task.id); const reopened = reopenTask(cwd, task.id);
expect(reopened.status).toBe('open'); expect(reopened.status).toBe('open');
expect(reopened.assignedTo).toBe('kimi');
expect(reopened.claimedBy).toBeUndefined();
const listed = listTasks(cwd, { status: 'open' }); const listed = listTasks(cwd, { status: 'open' });
expect(listed).toHaveLength(1); expect(listed).toHaveLength(1);
}); });
@ -122,4 +131,23 @@ describe('taskService', () => {
it('deleteTask is idempotent (deleting a missing task does not throw)', () => { it('deleteTask is idempotent (deleting a missing task does not throw)', () => {
expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow(); expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow();
}); });
it('dispatches a session-less agent explicitly', () => {
init(cwd, { projectName: 'dispatch-test', yes: true });
const config = loadConfig(cwd);
config.agents = { ...(config.agents ?? {}), backyard: { role: 'implementer', dispatch: 'architect' } };
saveConfig(cwd, config);
const task = createTask(cwd, { title: 'Backend', assignedTo: 'backyard' });
expect(dispatchTask(cwd, task.id, 'backyard')).toMatchObject({
status: 'in_progress', assignedTo: 'backyard', claimedBy: 'backyard',
});
});
it('records external work directly as a provenance-marked done task', () => {
init(cwd, { projectName: 'record-test', yes: true });
const task = recordExternalTask(cwd, { title: 'Already shipped', doneBy: 'codex' });
expect(task).toMatchObject({ status: 'done', origin: 'external', doneBy: 'codex', claimedBy: 'codex' });
expect(task.recordedAt).toBeTruthy();
expect(getTask(cwd, task.id).body).toContain('Nachgetragen');
});
}); });

View File

@ -4,11 +4,12 @@ import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { init } from '../src/cli/commands/init.js'; import { init } from '../src/cli/commands/init.js';
import { loadConfig, saveConfig } from '../src/core/config.js'; import { loadConfig, saveConfig } from '../src/core/config.js';
import { createTask, claimTask, getTask } from '../src/core/services/taskService.js'; import { createTask, claimTask, getTask, reviewTask } from '../src/core/services/taskService.js';
import { listMessages } from '../src/core/services/messageService.js'; import { listMessages } from '../src/core/services/messageService.js';
import { readTaskLog, appendTaskLog } from '../src/core/services/taskLogService.js'; import { readTaskLog, appendTaskLog } from '../src/core/services/taskLogService.js';
import { startWatchdog, resetWatchdog } from '../src/server/watchdog.js'; import { startWatchdog, resetWatchdog } from '../src/server/watchdog.js';
import { eventBus, type AgentHubEvent } from '../src/server/events.js'; import { eventBus, type AgentHubEvent } from '../src/server/events.js';
import { enterLoop, leaveLoop, resetPresence } from '../src/core/services/presenceService.js';
/** /**
* Watchdog thresholds (TSK-0226). Fake timers drive both the scan interval and * Watchdog thresholds (TSK-0226). Fake timers drive both the scan interval and
@ -26,6 +27,7 @@ describe('watchdog', () => {
const HIGH = 2_000; const HIGH = 2_000;
const DEFAULT = 5_000; const DEFAULT = 5_000;
const STALE = 4_000; const STALE = 4_000;
const REVIEW = 3_000;
beforeEach(() => { beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-watchdog-')); cwd = mkdtempSync(join(tmpdir(), 'ah-watchdog-'));
@ -37,9 +39,11 @@ describe('watchdog', () => {
unclaimedHighMs: HIGH, unclaimedHighMs: HIGH,
unclaimedDefaultMs: DEFAULT, unclaimedDefaultMs: DEFAULT,
staleInProgressMs: STALE, staleInProgressMs: STALE,
staleReviewMs: REVIEW,
}; };
saveConfig(cwd, config); saveConfig(cwd, config);
resetWatchdog(); resetWatchdog();
resetPresence();
events = []; events = [];
eventBus.on('change', onChange); eventBus.on('change', onChange);
vi.useFakeTimers(); vi.useFakeTimers();
@ -111,6 +115,38 @@ describe('watchdog', () => {
expect(remindersFor('kimi')).toHaveLength(1); expect(remindersFor('kimi')).toHaveLength(1);
}); });
it('alerts the architect when an assigned task targets an agent known to be out of the work loop', async () => {
createTask(cwd, { title: 'Unheard task', priority: 'high', assignedTo: 'codex' });
enterLoop('codex');
leaveLoop('codex', 'client timeout');
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(HIGH + 100);
const alerts = listMessages(cwd).filter(
(m) => m.to === 'claude' && m.text.includes('not in agenthub_work'),
);
expect(alerts).toHaveLength(1);
expect(alerts[0].taskId).toBe('TSK-0001');
});
it('routes architect-dispatched agents to the architect once and never re-notifies the agent', async () => {
const config = loadConfig(cwd);
config.agents = {
...(config.agents ?? {}),
claude: { role: 'architect', dispatch: 'loop' },
backyard: { role: 'implementer', dispatch: 'architect' },
};
saveConfig(cwd, config);
createTask(cwd, { title: 'Backend task', priority: 'high', assignedTo: 'backyard' });
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(HIGH + 100);
expect(remindersFor('backyard')).toHaveLength(0);
const notices = () => listMessages(cwd).filter((m) => m.to === 'claude' && m.text.includes('wird von dir gestartet'));
expect(notices()).toHaveLength(1);
await vi.advanceTimersByTimeAsync(HIGH * 2);
expect(notices()).toHaveLength(1);
});
it('alerts the architect about a silent in_progress task — without reassigning', async () => { it('alerts the architect about a silent in_progress task — without reassigning', async () => {
createTask(cwd, { title: 'WIP', priority: 'high', assignedTo: 'kimi' }); createTask(cwd, { title: 'WIP', priority: 'high', assignedTo: 'kimi' });
claimTask(cwd, 'TSK-0001', 'kimi'); claimTask(cwd, 'TSK-0001', 'kimi');
@ -145,4 +181,18 @@ describe('watchdog', () => {
} }
expect(listMessages(cwd).filter((m) => m.text.startsWith('Watchdog:'))).toHaveLength(0); expect(listMessages(cwd).filter((m) => m.text.startsWith('Watchdog:'))).toHaveLength(0);
}); });
it('alerts the architect when a review waits too long', async () => {
createTask(cwd, { title: 'Review me', assignedTo: 'kimi' });
claimTask(cwd, 'TSK-0001', 'kimi');
reviewTask(cwd, 'TSK-0001');
stop = startWatchdog(cwd);
await vi.advanceTimersByTimeAsync(REVIEW + INTERVAL);
const alerts = listMessages(cwd).filter(
(m) => m.to === 'claude' && m.text.includes('waited in review'),
);
expect(alerts).toHaveLength(1);
expect(alerts[0].taskId).toBe('TSK-0001');
});
}); });