diff --git a/src/cli/commands/health.ts b/src/cli/commands/health.ts index 96929d6..325fd2c 100644 --- a/src/cli/commands/health.ts +++ b/src/cli/commands/health.ts @@ -28,6 +28,9 @@ export function printHealth(h: HealthReport): void { `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`, ); + for (const error of h.indexErrors ?? []) { + console.log(`! index error · ${error.filePath} · ${error.error}`); + } if (h.agents.length === 0) { console.log('no agents yet'); return; @@ -36,6 +39,11 @@ export function printHealth(h: HealthReport): void { for (const a of h.agents) { const light = LIGHT[a.state] ?? '○'; 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}`); } } diff --git a/src/cli/commands/start.ts b/src/cli/commands/start.ts index 98a2769..e507e27 100644 --- a/src/cli/commands/start.ts +++ b/src/cli/commands/start.ts @@ -5,6 +5,7 @@ import { claimTask as svcClaimTask, } from '../../core/services/taskService.js'; import { listHandoffs as svcListHandoffs, getHandoff as svcGetHandoff } from '../../core/services/handoffService.js'; +import { agentIdentity } from '../../core/services/identityService.js'; export interface AgentContext { serverUrl?: string; @@ -79,12 +80,19 @@ const PRIORITY_RANK: Record = { critical: 4, high: 3, medium: 2, export async function findAddressedOpenTask( ctx: AgentContext, ): 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 ? await remoteClient.listTasks(ctx.serverUrl, { 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; const tasks = await listOpenRoleTasks(ctx); @@ -92,14 +100,14 @@ export async function findAddressedOpenTask( const addressedByHandoff = new Set( 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)), ); const mine = tasks.filter( (t) => (t.title ?? '').toLowerCase().startsWith(`${a}:`) || addressedByHandoff.has(t.id) || - (t.assignedTo && String(t.assignedTo).toLowerCase() === a), + (t.assignedTo && matchesAgent(t.assignedTo)), ); if (mine.length === 0) return undefined; diff --git a/src/cli/commands/task.ts b/src/cli/commands/task.ts index 188e1b5..6adab34 100644 --- a/src/cli/commands/task.ts +++ b/src/cli/commands/task.ts @@ -1,5 +1,5 @@ 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 type { Task } from '../../core/schema.js'; @@ -42,6 +42,14 @@ export function taskClaim(cwd: string, id: string, agentName: string): void { claimTask(cwd, id, 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( cwd: string, diff --git a/src/cli/index.ts b/src/cli/index.ts index 74a84d8..49d1ace 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { init } from './commands/init.js'; import { status } from './commands/status.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 { decisionCreate, decisionList } from './commands/decision.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); } }); + taskCmd + .command('dispatch ') + .description('Architect: explicitly start a session-less agent for an open task') + .requiredOption('--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 ', '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 .command('done <id>') .description('Mark a task as done') diff --git a/src/cli/remoteClient.ts b/src/cli/remoteClient.ts index 25cc30c..1ddd88a 100644 --- a/src/cli/remoteClient.ts +++ b/src/cli/remoteClient.ts @@ -43,10 +43,27 @@ export const remoteClient = { 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> { 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> { 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> { 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[]> { 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> { 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( baseUrl: string, diff --git a/src/core/schema.ts b/src/core/schema.ts index e24db87..eb45195 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -6,6 +6,7 @@ export const MemoryCategory = z.enum(['architecture', 'product', 'technical', 'i export const Priority = z.enum(['low', 'medium', 'high', 'critical']); export const Role = z.enum(['architect', 'implementer', 'reviewer', 'tester']); export const DelegationMode = z.enum(['manual', 'suggest', 'auto']); +export const AgentDispatch = z.enum(['loop', 'architect']); export const TaskSchema = z.object({ id: z.string().regex(/^TSK-\d{4}$/), @@ -27,6 +28,9 @@ export const TaskSchema = z.object({ doneBy: z.string().optional(), doneTokens: 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({ @@ -154,6 +158,10 @@ export const RoleConfigSchema = z.object({ */ export const AgentConfigSchema = z.object({ 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". */ model: z.string().optional(), /** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */ @@ -184,6 +192,8 @@ export const OrgNodeSchema = z.object({ parentId: z.string().optional(), /** Provider override for the icon when there is no linked agent. */ 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>; @@ -202,6 +212,8 @@ export const WatchdogConfigSchema = z.object({ unclaimedDefaultMs: z.number().int().positive().default(10 * 60_000), /** IN_PROGRESS with no task-log line for this long → alert the architect. */ 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({}); export const ConfigSchema = z.object({ diff --git a/src/core/services/handoffService.ts b/src/core/services/handoffService.ts index d4c6de4..1371706 100644 --- a/src/core/services/handoffService.ts +++ b/src/core/services/handoffService.ts @@ -4,15 +4,17 @@ import { getNextId } from '../counter.js'; import { readEntity, writeEntity } from '../files.js'; import { HandoffSchema, type Handoff } from '../schema.js'; import { Index } from '../index.js'; +import { resolveAgentName } from './identityService.js'; export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Handoff { const now = new Date().toISOString(); + const toAgent = options.toAgent ? resolveAgentName(cwd, options.toAgent) : undefined; const handoff: Handoff = HandoffSchema.parse({ id: getNextId(cwd, 'handoff'), fromRole: options.fromRole ?? 'user', toRole: options.toRole ?? 'user', fromAgent: options.fromAgent, - toAgent: options.toAgent, + toAgent, taskId: options.taskId, summary: options.summary ?? 'Handoff', context: options.context ?? '', diff --git a/src/core/services/identityService.ts b/src/core/services/identityService.ts new file mode 100644 index 0000000..c7774be --- /dev/null +++ b/src/core/services/identityService.ts @@ -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 }; +} diff --git a/src/core/services/messageService.ts b/src/core/services/messageService.ts index 0c361eb..cafc925 100644 --- a/src/core/services/messageService.ts +++ b/src/core/services/messageService.ts @@ -4,6 +4,7 @@ import { getNextId } from '../counter.js'; import { readEntity, writeEntity } from '../files.js'; import { MessageSchema, type Message } from '../schema.js'; import { Index } from '../index.js'; +import { agentIdentity, resolveAgentName } from './identityService.js'; function indexEntryFor(record: Message, filePath: string) { return { @@ -28,10 +29,11 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess if (!options.text) throw new Error('Message requires text'); const now = new Date().toISOString(); + const to = resolveAgentName(cwd, options.to); const record: Message = MessageSchema.parse({ id: getNextId(cwd, 'message'), from: options.from, - to: options.to, + to, text: options.text, taskId: options.taskId, 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. */ -export function messageRecipientAliases(agent: string): Set<string> { - const key = agent.toLowerCase(); - const aliases = new Set([agent, key]); +export function messageRecipientAliases(cwdOrAgent: string, agent?: string): Set<string> { + if (agent) return agentIdentity(cwdOrAgent, agent).names; + // 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') { - aliases.add('architect'); - aliases.add('claude'); + names.add('architect'); + names.add('claude'); } - return aliases; + return names; } export interface InboxMessage { @@ -89,7 +94,7 @@ export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boole const index = new Index(cwd); const all = index.list('message'); index.close(); - const recipients = messageRecipientAliases(agent); + const recipients = messageRecipientAliases(cwd, agent); const filtered = all .filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase())) .filter((m) => !opts.unreadOnly || m.status === 'unread'); diff --git a/src/core/services/orgService.ts b/src/core/services/orgService.ts index e595ae9..9ddcb4b 100644 --- a/src/core/services/orgService.ts +++ b/src/core/services/orgService.ts @@ -53,6 +53,10 @@ export function syncOrgFromFile(cwd: string, from?: string): { count: number; fi const org = parseOrg(block); const config = loadConfig(root); 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); return { count: org.length, file }; } diff --git a/src/core/services/presenceService.ts b/src/core/services/presenceService.ts index 3c0176a..fc6892e 100644 --- a/src/core/services/presenceService.ts +++ b/src/core/services/presenceService.ts @@ -28,11 +28,19 @@ export type AgentLight = 'active' | 'busy' | 'idle' | 'stale'; export interface AgentHealth { name: string; role: string; + dispatch: 'loop' | 'architect'; state: AgentLight; /** Busy-on task (in_progress) or — for stale agents — the waiting open task. */ taskId?: string; + waitingTaskId?: string; lastSeen?: string; lastSeenAgoSec?: number; + inLoop: boolean; + loopSince?: string; + loopSinceAgoSec?: number; + loopExitAt?: string; + loopExitAgoSec?: number; + loopExitReason?: string; } export interface HealthReport { @@ -42,9 +50,11 @@ export interface HealthReport { uptimeSec: number; counts: { tasks: number; open: number; inProgress: number; review: number; unreadMessages: number }; agents: AgentHealth[]; + indexErrors?: Array<{ filePath: string; error: string; at: string }>; } 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). */ 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); } +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. */ export function resetPresence(): void { lastSeen.clear(); + loops.clear(); } /** One agent's traffic light, derived from presence + its task load. */ @@ -63,11 +98,12 @@ export function agentLight( name: string, tasks: Array<{ id: string; status?: string; assignedTo?: string; claimedBy?: string }>, now: number = Date.now(), -): Pick<AgentHealth, 'state' | 'taskId' | 'lastSeen' | 'lastSeenAgoSec'> { +): Omit<AgentHealth, 'name' | 'role' | 'dispatch'> { const seen = lastSeen.get(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 seenAgo = seen === undefined ? undefined : now - seen; + const loop = loops.get(name); let state: AgentLight; if (seenAgo !== undefined && seenAgo < ACTIVE_WINDOW_MS) { @@ -85,8 +121,15 @@ export function agentLight( return { state, taskId: busy?.id ?? (state === 'stale' ? waiting?.id : undefined), + waitingTaskId: waiting?.id, lastSeen: seen === undefined ? undefined : new Date(seen).toISOString(), 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 roster = getRoster(cwd); 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). 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) => ({ name, role: roleByName.get(name) ?? 'implementer', + dispatch: dispatchByName.get(name) ?? 'loop', ...agentLight(name, tasks, now), })); diff --git a/src/core/services/rosterService.ts b/src/core/services/rosterService.ts index 6e5ef45..1146568 100644 --- a/src/core/services/rosterService.ts +++ b/src/core/services/rosterService.ts @@ -10,6 +10,7 @@ export interface RosterEntry { kind?: string; description?: string; budgetEur?: number; + dispatch: 'loop' | 'architect'; } /** 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), description: a.description, budgetEur: a.budgetEur, + dispatch: a.dispatch, })); } @@ -60,6 +62,6 @@ export function getRoster(cwd: string): RosterEntry[] { return Array.from(names).map((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' }; }); } diff --git a/src/core/services/taskService.ts b/src/core/services/taskService.ts index 7b6405f..caddad7 100644 --- a/src/core/services/taskService.ts +++ b/src/core/services/taskService.ts @@ -6,9 +6,11 @@ import { readEntity, writeEntity } from '../files.js'; import { TaskSchema, type Task } from '../schema.js'; import { Index } from '../index.js'; import { loadConfig } from '../config.js'; +import { resolveAgentName } from './identityService.js'; export function createTask(cwd: string, options: Partial<Task> = {}): Task { const now = new Date().toISOString(); + const assignedTo = options.assignedTo ? resolveAgentName(cwd, options.assignedTo) : undefined; const task: Task = TaskSchema.parse({ id: getNextId(cwd, 'task'), title: options.title ?? 'Untitled', @@ -16,8 +18,8 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task { status: 'open', priority: options.priority ?? 'medium', role: options.role, - assignedTo: options.assignedTo, - claimedBy: options.claimedBy, + assignedTo, + claimedBy: options.claimedBy ? resolveAgentName(cwd, options.claimedBy) : undefined, reviewer: options.reviewer, createdAt: now, updatedAt: now, @@ -33,6 +35,50 @@ export function createTask(cwd: string, options: Partial<Task> = {}): 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']> { const index = new Index(cwd); 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. */ export function claimTask(cwd: string, id: string, agentName: string): Task { + agentName = resolveAgentName(cwd, agentName); const { task } = getTask(cwd, id); // Idempotent: the same agent re-claiming its own in-progress task is a no-op. if (task.status === 'in_progress' && task.claimedBy === agentName) { @@ -116,9 +163,16 @@ export function doneTask( id: string, meta?: { doneBy?: string; doneTokens?: number; doneDuration?: number }, ): 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, { status: 'done', - doneBy: meta?.doneBy, + doneBy: meta?.doneBy ?? task.claimedBy, doneTokens: meta?.doneTokens, doneDuration: meta?.doneDuration, }); @@ -126,6 +180,12 @@ export function doneTask( export function reviewTask(cwd: string, id: string, reviewer?: string): Task { 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 patch: Partial<Task> = { status: 'review' }; 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 { + 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' }); } 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. */ 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 { @@ -178,7 +251,7 @@ function getPreferredReviewer(cwd: string): string | undefined { } } -function toIndexEntry(task: Task, filePath: string) { +export function toIndexEntry(task: Task, filePath: string) { return { id: task.id, type: 'task', diff --git a/src/mcp/server.ts b/src/mcp/server.ts index fff4af9..f62f156 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -27,6 +27,21 @@ import { getStatus } from '../core/services/statusService.js'; import { discoverServer as discoverHubServer, resolveReachableServerUrl } from '../discovery.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). * @@ -194,10 +209,16 @@ export async function startMcpServer(cwd: string): Promise<void> { }); 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() }, async ({ agent, role, timeoutSec, unattended }) => { 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 useServerUrl = (nextServerUrl?: string) => { 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.'; } 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 ? `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.`; if (!remote) return asText(emptyMsg); - const result = await waitForTask(serverUrl!, finder, timeoutSec ?? 300); - if (result) return asText({ ...result, loop: LOOP }); + const effectiveTimeout = timeoutSec ?? DEFAULT_WORK_TIMEOUT_SEC; + 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'; - 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.', - { status: z.string().optional(), role: z.string().optional() }, - async ({ status, role }) => asText(remote ? await remoteClient.listTasks(serverUrl!, { status, role }) : listTasks(root, { status, 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(), limit: z.number().int().positive().max(200).optional() }, + 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.', { 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.', { 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).', { id: z.string() }, diff --git a/src/server/agentHealth.ts b/src/server/agentHealth.ts index 91c1c89..6c71b53 100644 --- a/src/server/agentHealth.ts +++ b/src/server/agentHealth.ts @@ -54,7 +54,11 @@ function renderAgentCardsSSR(agents: AgentHealth[]): string { <span class="ac-role">${escapeHtml(a.role)}</span> <span class="light light-${a.state}" data-light>${a.state}</span> </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"> <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> @@ -250,7 +254,12 @@ export function renderAgentHealthHtml(cwd: string, startedAtMs: number): string light.textContent = a.state; 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>' : ''; - 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 newTrack = trackStatus(a.name); if (tr.innerHTML !== newTrack) tr.innerHTML = newTrack; diff --git a/src/server/board/columns.ts b/src/server/board/columns.ts index 018e31a..ae9a7bd 100644 --- a/src/server/board/columns.ts +++ b/src/server/board/columns.ts @@ -58,6 +58,14 @@ export function modalsHtml(): string { <option value="critical">critical</option> </select> </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> <div class="modal-actions"> <button type="button" class="modal-cancel" id="tmCancel">Cancel</button> @@ -622,6 +630,7 @@ export function columnsJs(opts: ColumnsJsOpts): string { var data = null; try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {} if (type === 'task') { refresh(); feedFromEvent(data); } + if (type === 'agent' && window.refreshAgentHealth) window.refreshAgentHealth(); refreshBudget(); }; source.onerror = function() { @@ -683,6 +692,11 @@ export function columnsJs(opts: ColumnsJsOpts): string { if (!res.ok) throw new Error('create failed (' + res.status + ')'); 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) { var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) { @@ -692,6 +706,13 @@ export function columnsJs(opts: ColumnsJsOpts): string { } 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) { var el = document.querySelector('.card[data-id="' + id + '"]'); if (!el) return; @@ -720,8 +741,14 @@ export function columnsJs(opts: ColumnsJsOpts): string { // in the title, else mark it as a manual board claim. var titledAgent = assigned ? '' : agentFromTitle(id); var agent = assigned || titledAgent || 'manual'; - await patchTask(id, { status: 'in_progress', assignedTo: agent }); - toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (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 }); + toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : '')); + } } else if (status === 'review') { var reviewed = await patchTask(id, { status: 'review' }); toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : '')); @@ -873,12 +900,25 @@ export function columnsJs(opts: ColumnsJsOpts): string { if (e.target.closest && e.target.closest('.console-overlay')) e.preventDefault(); }); // 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) { var tog = e.target.closest && e.target.closest('[data-console-toggle]'); if (!tog) return; e.preventDefault(); e.stopPropagation(); var id = tog.getAttribute('data-console-toggle'); 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; tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); 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'); if (!btn || !modal || !form) return; 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() { modal.hidden = false; btn.setAttribute('aria-expanded', 'true'); @@ -957,8 +1004,10 @@ export function columnsJs(opts: ColumnsJsOpts): string { var priority = document.getElementById('tmPriority').value || undefined; try { // No assignee/role: the task lands open in the architect's lap to route. - var task = await postTask({ title: title, description: description, priority: priority }); - toast(task.id + ' created'); + var task = externalInput.checked + ? 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(); await refresh(); await refreshBudget(); flashCard(task.id); } catch (err) { toast(err.message || 'create failed', { error: true }); } diff --git a/src/server/board/sidebar.ts b/src/server/board/sidebar.ts index ab89398..6fe69bb 100644 --- a/src/server/board/sidebar.ts +++ b/src/server/board/sidebar.ts @@ -74,9 +74,15 @@ window.__b2RenderAgents = function (report) { var agents = report && report.agents ? report.agents : []; if (!agents.length) { box.innerHTML = '<div class="budget-empty">no agents yet</div>'; return; } box.innerHTML = agents.map(function (a) { - var detail = a.state === 'busy' && a.taskId ? 'busy · ' + a.taskId - : a.state === 'stale' && a.taskId ? 'stale · ' + a.taskId + ' open' - : a.state; + var loop = a.dispatch === 'architect' + ? (a.taskId ? 'architect started' : a.waitingTaskId ? 'wartet auf Architekten-Start' : 'architect dispatch') + : 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) + '">' + '<span class="b2-light st-' + esc(a.state) + '" aria-hidden="true"></span>' + '<span class="b2-agent-name">' + esc(a.name) + '</span>' + @@ -86,6 +92,7 @@ window.__b2RenderAgents = function (report) { async function refreshAgentHealth() { try { window.__b2RenderAgents(await getJSON('/health')); } catch (_) {} } +window.refreshAgentHealth = refreshAgentHealth; refreshAgentHealth(); setInterval(refreshAgentHealth, 5000); ${v1BudgetJs}`; diff --git a/src/server/eventLog.ts b/src/server/eventLog.ts index 0bd1720..7ac1b80 100644 --- a/src/server/eventLog.ts +++ b/src/server/eventLog.ts @@ -52,6 +52,20 @@ export function listHubEventsAfter(cwd: string, afterSeq: number, limit = 1000): 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 { cachedDb?.close(); cachedDb = undefined; diff --git a/src/server/fsWatch.ts b/src/server/fsWatch.ts index 3b6ce7d..6c91cf8 100644 --- a/src/server/fsWatch.ts +++ b/src/server/fsWatch.ts @@ -4,6 +4,9 @@ import { readEntity } from '../core/files.js'; import { getEntityDir, type EntityType } from '../core/paths.js'; import { emitChange, seenRecently, signatureOf } 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. @@ -32,6 +35,17 @@ const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [ // 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. 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 { return value === undefined || value === null ? undefined : String(value); @@ -104,13 +118,15 @@ export function startEntityWatcher(cwd: string): () => void { const key = `${type}:${name}`; const existing = timers.get(key); if (existing) clearTimeout(existing); - timers.set( - key, - setTimeout(() => { + const filePath = getEntityDir(cwd, dir) + '/' + name; + const schedule = (attempt: number, delay: number) => { + timers.set(key, setTimeout(() => { timers.delete(key); - processFile(getEntityDir(cwd, dir) + '/' + name, type); - }, DEBOUNCE_MS), - ); + const error = processFile(cwd, filePath, type); + if (error && attempt < MAX_READ_ATTEMPTS) schedule(attempt + 1, RETRY_MS); + }, delay)); + }; + schedule(1, DEBOUNCE_MS); }); watchers.push(watcher); } 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>; try { - fm = readEntity(filePath).frontmatter; - } catch { - // Deleted again, or read mid-write -> skip; a later settled write re-fires. - return; + const entity = readEntity(filePath); + fm = entity.frontmatter; + if (type === 'task') { + 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); diff --git a/src/server/routes.ts b/src/server/routes.ts index 812b281..fcefc3e 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,5 +1,5 @@ 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 { appendTaskLog, readTaskLog } from '../core/services/taskLogService.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 { computeBudget } from '../core/services/budgetService.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 { agentIdentity } from '../core/services/identityService.js'; import { renderActivityHtml } from './activity.js'; import { renderAgentHealthHtml } from './agentHealth.js'; import { renderBoardHtml } from './board/index.js'; @@ -23,11 +24,12 @@ import { renderMessagesHtml } from './messages.js'; import { renderTaskDetailHtml } from './taskDetail.js'; import { configureDurableEvents, eventBus, emitChange } 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 { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { join, extname, normalize } from 'node:path'; +import { getFsWatchErrors } from './fsWatch.js'; 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. - 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 // (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); 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 ────────────────────────────────────────────────────────────── app.get('/status', async () => ({ body: getStatus(cwd) })); @@ -296,6 +374,16 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise ); 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) => { 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 }); 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) => { 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 // change). Fires task/updated so a waiting `agenthub work` auto-claims it. 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); emitChange( { @@ -379,7 +484,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise } let task: Task; - switch (patch.status) { + try { + switch (patch.status) { case 'in_progress': { const current = getTask(cwd, id).task; @@ -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 // 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. - try { - task = claimTask(cwd, id, agent); - } catch (err) { - return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task'); - } + task = claimTask(cwd, id, agent); stampSeen(agent); } break; @@ -414,8 +516,13 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise case 'open': task = reopenTask(cwd, id); break; - default: - return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled'); + default: + 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 / diff --git a/src/server/watchdog.ts b/src/server/watchdog.ts index 963f58b..0fe0c24 100644 --- a/src/server/watchdog.ts +++ b/src/server/watchdog.ts @@ -4,6 +4,8 @@ import { listTasks, getTask } from '../core/services/taskService.js'; import { readTaskLog, appendTaskLog } from '../core/services/taskLogService.js'; import { createMessage } from '../core/services/messageService.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). @@ -75,9 +77,40 @@ export function runWatchdogScan(cwd: string, now: number = Date.now()): number { const threshold = thresholdFor(priority, cfg); const waitingMs = now - Date.parse(t.updatedAt || t.createdAt); 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); 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. 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}`); } } + + 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; diff --git a/tests/activityService.test.ts b/tests/activityService.test.ts index 7901075..13dc1c9 100644 --- a/tests/activityService.test.ts +++ b/tests/activityService.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; 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 { addMemory } from '../src/core/services/memoryService.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', () => { 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 }); const items = getTaskActivity(cwd, task.id); const status = items.find((i) => i.kind === 'status'); diff --git a/tests/architectBudget.test.ts b/tests/architectBudget.test.ts index fc26b6e..4183471 100644 --- a/tests/architectBudget.test.ts +++ b/tests/architectBudget.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; 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 { createMessage } from '../src/core/services/messageService.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', () => { // Implementer-owned task; the architect only coordinates around it. createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' }); + claimTask(cwd, 'TSK-0001', 'codex'); reviewTask(cwd, 'TSK-0001', 'claude'); createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', fromAgent: 'claude', toAgent: 'codex', taskId: 'TSK-0001', summary: 'scope' }); 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', () => { createTask(cwd, { title: 'Feature', role: 'implementer', assignedTo: 'codex' }); + claimTask(cwd, 'TSK-0001', 'codex'); reviewTask(cwd, 'TSK-0001', 'claude'); // Architect approves: doneBy = claude, owner = codex. doneTask(cwd, 'TSK-0001', { doneBy: 'claude', doneTokens: 8000 }); diff --git a/tests/health.test.ts b/tests/health.test.ts index c1461cb..320640e 100644 --- a/tests/health.test.ts +++ b/tests/health.test.ts @@ -13,7 +13,10 @@ interface HealthBody { startedAt: string; uptimeSec: 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)', () => { @@ -60,6 +63,22 @@ describe('GET /health + lastSeen stamping (TSK-0226)', () => { 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 () => { await app.inject({ method: 'POST', url: '/messages', payload: { from: 'codex', to: 'claude', text: 'ping' } }); const h = await getHealth(); diff --git a/tests/identityService.test.ts b/tests/identityService.test.ts new file mode 100644 index 0000000..706a32e --- /dev/null +++ b/tests/identityService.test.ts @@ -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); + }); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index b753a17..31a966e 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -4,7 +4,12 @@ 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 { 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', () => { let cwd: string; @@ -35,3 +40,20 @@ describe('MCP context resolution', () => { 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'); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index 426d904..e39acc2 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { buildApp } from '../src/server/index.js'; import { init } from '../src/cli/commands/init.js'; +import { loadConfig, saveConfig } from '../src/core/config.js'; describe('server routes', () => { let cwd: string; @@ -37,6 +38,57 @@ describe('server routes', () => { 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 () => { const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' }); expect(res.statusCode).toBe(404); @@ -64,11 +116,33 @@ describe('server routes', () => { it('PATCH /tasks/:id → review', 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' } }); const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); expect(res.statusCode).toBe(200); 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 () => { 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' } }); @@ -83,6 +157,7 @@ describe('server routes', () => { 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: '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' } }); expect(res.statusCode).toBe(200); const task = JSON.parse(res.payload); @@ -103,8 +178,8 @@ describe('server routes', () => { it('PATCH /tasks/:id → open (reopen)', async () => { 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: 'cancelled' } }); + 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 res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } }); expect(res.statusCode).toBe(200); 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 () => { 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: '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-0002', payload: { status: 'cancelled' } }); @@ -203,6 +279,8 @@ describe('server routes', () => { 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: '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', @@ -251,6 +329,16 @@ describe('server routes', () => { 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({ method: 'PATCH', 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 () => { 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({ method: 'PATCH', url: '/tasks/TSK-0001', diff --git a/tests/sse.test.ts b/tests/sse.test.ts index 073f553..430857a 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -8,7 +8,7 @@ */ 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 { join } from 'path'; 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 { parseSSEBuffer, formatEvent, watchEvents } from '../src/cli/commands/watch.js'; import { init } from '../src/cli/commands/init.js'; -import { startEntityWatcher } from '../src/server/fsWatch.js'; -import { createTask, claimTask } from '../src/core/services/taskService.js'; +import { getFsWatchErrors, resetFsWatchErrors, startEntityWatcher } from '../src/server/fsWatch.js'; +import { createTask, claimTask, listTasks } from '../src/core/services/taskService.js'; import { listHubEventsAfter } from '../src/server/eventLog.js'; // ─── 1. Unit: SSE buffer parser ────────────────────────────────────────────── @@ -187,12 +187,13 @@ describe('eventBus mutations', () => { it('emits task/updated when PATCH /tasks/:id changes status', async () => { 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' } }); expect(collected).toHaveLength(1); 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 () => { @@ -206,6 +207,7 @@ describe('eventBus mutations', () => { 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: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } }); collected.length = 0; await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); expect(collected).toHaveLength(1); @@ -214,6 +216,7 @@ describe('eventBus mutations', () => { 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: '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: 'open' } }); @@ -560,6 +563,7 @@ describe('fsWatch emits for non-REST (CLI/file) writes', () => { stop(); eventBus.off('change', onChange); collected.length = 0; + resetFsWatchErrors(); rmSync(cwd, { recursive: true, force: true }); }); @@ -586,6 +590,37 @@ describe('fsWatch emits for non-REST (CLI/file) writes', () => { expect(ev).toBeDefined(); expect(ev).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'windows-claude' }); }, 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 ──────────── diff --git a/tests/taskService.test.ts b/tests/taskService.test.ts index a136f87..e86b8ae 100644 --- a/tests/taskService.test.ts +++ b/tests/taskService.test.ts @@ -4,10 +4,11 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { createTask, listTasks, getTask, - claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, + claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, recordExternalTask, dispatchTask, } from '../src/core/services/taskService.js'; import { init } from '../src/cli/commands/init.js'; import { Index } from '../src/core/index.js'; +import { loadConfig, saveConfig } from '../src/core/config.js'; describe('taskService', () => { let cwd: string; @@ -39,12 +40,15 @@ describe('taskService', () => { expect(claimed.assignedTo).toBe('codex'); expect(claimed.claimedBy).toBe('codex'); expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' }); + reviewTask(cwd, task.id); const done = doneTask(cwd, task.id); expect(done.status).toBe('done'); + expect(done.claimedBy).toBe('codex'); }); it('transitions a task to review', () => { const task = createTask(cwd, { title: 'C', role: 'implementer' }); + claimTask(cwd, task.id, 'codex'); const inReview = reviewTask(cwd, task.id); expect(inReview.status).toBe('review'); // Verify index is updated @@ -55,6 +59,7 @@ describe('taskService', () => { it('records an explicit reviewer separately from the assignee', () => { const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' }); + claimTask(cwd, task.id, 'codex'); const inReview = reviewTask(cwd, task.id, 'claude'); expect(inReview.status).toBe('review'); expect(inReview.assignedTo).toBe('codex'); @@ -68,6 +73,7 @@ describe('taskService', () => { it('uses the preferred reviewer when reviewTask is called without one', () => { init(cwd, { projectName: 'reviewer-test', yes: true }); const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' }); + claimTask(cwd, task.id, 'codex'); const inReview = reviewTask(cwd, task.id); expect(inReview.reviewer).toBe('claude'); }); @@ -81,10 +87,13 @@ describe('taskService', () => { }); it('reopens a task (any status → open)', () => { - const task = createTask(cwd, { title: 'E', role: 'implementer' }); - cancelTask(cwd, task.id); + const task = createTask(cwd, { title: 'E', role: 'implementer', assignedTo: 'kimi' }); + claimTask(cwd, task.id, 'kimi'); + reviewTask(cwd, task.id); const reopened = reopenTask(cwd, task.id); expect(reopened.status).toBe('open'); + expect(reopened.assignedTo).toBe('kimi'); + expect(reopened.claimedBy).toBeUndefined(); const listed = listTasks(cwd, { status: 'open' }); expect(listed).toHaveLength(1); }); @@ -122,4 +131,23 @@ describe('taskService', () => { it('deleteTask is idempotent (deleting a missing task does not throw)', () => { 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'); + }); }); diff --git a/tests/watchdog.test.ts b/tests/watchdog.test.ts index ee9ec58..23abb8e 100644 --- a/tests/watchdog.test.ts +++ b/tests/watchdog.test.ts @@ -4,11 +4,12 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { init } from '../src/cli/commands/init.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 { readTaskLog, appendTaskLog } from '../src/core/services/taskLogService.js'; import { startWatchdog, resetWatchdog } from '../src/server/watchdog.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 @@ -26,6 +27,7 @@ describe('watchdog', () => { const HIGH = 2_000; const DEFAULT = 5_000; const STALE = 4_000; + const REVIEW = 3_000; beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-watchdog-')); @@ -37,9 +39,11 @@ describe('watchdog', () => { unclaimedHighMs: HIGH, unclaimedDefaultMs: DEFAULT, staleInProgressMs: STALE, + staleReviewMs: REVIEW, }; saveConfig(cwd, config); resetWatchdog(); + resetPresence(); events = []; eventBus.on('change', onChange); vi.useFakeTimers(); @@ -111,6 +115,38 @@ describe('watchdog', () => { 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 () => { createTask(cwd, { title: 'WIP', priority: 'high', assignedTo: 'kimi' }); claimTask(cwd, 'TSK-0001', 'kimi'); @@ -145,4 +181,18 @@ describe('watchdog', () => { } 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'); + }); });