feat(hub): Korrektheits- und Realtime-Härtung — Alias, Lifecycle, Presence, Architekten-Pulse (TSK-0242/0245/0249)
Drei vom Architekten abgenommene Tasks, gebündelt als Checkpoint: - TSK-0242: Agent-Alias-Mapping (kimi-ah → kimi kanonisiert, Rollen → preferredAgent), reopenTask räumt claimedBy ab, fsWatch reindiziert direkte Datei-Edits, work-Default 300s → 50s, task_list mit Limit. - TSK-0245: zwei Agent-Klassen (dispatch loop|architect). Watchdog mahnt architekt-getriebene Agenten nur noch EINMAL statt im Minutentakt; `task dispatch` startet sie explizit, `task record` trägt extern erledigte Arbeit mit origin=external nach. - TSK-0249: Lifecycle wird serverseitig erzwungen (open→review scheitert mit klarer Meldung), claimedBy/doneBy überleben bis done, Presence pro Agent, Review-Watchdog, GET /architect/pulse (1.4 kB statt 34 kB, since-Cursor, omitted statt stillem Abschneiden), unbekannter Agent → 400 statt 500. Alle Punkte live am laufenden Hub nachgemessen, nicht aus Agenten-Logs übernommen. Tests: 242 → 279 grün, tsc sauber. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9dfbf3f657
commit
6d088648da
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, number> = { 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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 <id>')
|
||||
.description('Architect: explicitly start a session-less agent for an open task')
|
||||
.requiredOption('--agent <agent>', 'Architect-dispatched agent')
|
||||
.action(async (id, options) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
await remoteClient.dispatchTask(serverUrl, id, options.agent);
|
||||
console.log(`AgentHub: Architect started ${options.agent} for ${id}`);
|
||||
});
|
||||
} else taskDispatch(projectCwd, id, options.agent);
|
||||
});
|
||||
taskCmd
|
||||
.command('record')
|
||||
.description('Record work already completed outside AgentHub')
|
||||
.requiredOption('--title <title>', 'Completed work')
|
||||
.requiredOption('--by <agent>', 'Who completed it')
|
||||
.option('--description <description>', 'Details')
|
||||
.option('--role <role>', 'Role')
|
||||
.option('--priority <priority>', 'Priority')
|
||||
.action(async (options) => {
|
||||
const payload = { title: options.title, doneBy: options.by, description: options.description, role: options.role, priority: options.priority };
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const task = await remoteClient.recordExternalTask(serverUrl, payload);
|
||||
console.log(`AgentHub: External work recorded ${task.id} by ${task.doneBy}`);
|
||||
});
|
||||
} else taskRecord(projectCwd, payload);
|
||||
});
|
||||
taskCmd
|
||||
.command('done <id>')
|
||||
.description('Mark a task as done')
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 ?? '',
|
||||
|
||||
49
src/core/services/identityService.ts
Normal file
49
src/core/services/identityService.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { loadConfig } from '../config.js';
|
||||
|
||||
export interface AgentIdentity {
|
||||
canonical: string;
|
||||
names: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent, configured alias, or role name to the canonical roster
|
||||
* agent. Projects without an explicit roster keep accepting free-form agent
|
||||
* names for backward compatibility.
|
||||
*/
|
||||
export function resolveAgentName(cwd: string, recipient: string): string {
|
||||
const raw = recipient?.trim();
|
||||
if (!raw) throw new Error('Agent recipient is required');
|
||||
const key = raw.toLowerCase();
|
||||
let config;
|
||||
try {
|
||||
config = loadConfig(cwd);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
|
||||
const role = Object.entries(config.roles).find(([name]) => name.toLowerCase() === key);
|
||||
if (role) return role[1].preferredAgent;
|
||||
|
||||
const agents = config.agents;
|
||||
if (!agents || Object.keys(agents).length === 0) return raw;
|
||||
|
||||
for (const [canonical, entry] of Object.entries(agents)) {
|
||||
if (canonical.toLowerCase() === key || entry.aliases.some((alias) => alias.toLowerCase() === key)) {
|
||||
return canonical;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown agent, alias, or role: "${raw}"`);
|
||||
}
|
||||
|
||||
/** All names that should compare equal for task/handoff/inbox matching. */
|
||||
export function agentIdentity(cwd: string, recipient: string): AgentIdentity {
|
||||
const canonical = resolveAgentName(cwd, recipient);
|
||||
const config = loadConfig(cwd);
|
||||
const names = new Set<string>([canonical.toLowerCase()]);
|
||||
const entry = config.agents?.[canonical];
|
||||
for (const alias of entry?.aliases ?? []) names.add(alias.toLowerCase());
|
||||
for (const [role, roleConfig] of Object.entries(config.roles)) {
|
||||
if (roleConfig.preferredAgent.toLowerCase() === canonical.toLowerCase()) names.add(role.toLowerCase());
|
||||
}
|
||||
return { canonical, names };
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
|
||||
|
||||
@ -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' };
|
||||
});
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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() },
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 }); }
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 /
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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();
|
||||
|
||||
37
tests/identityService.test.ts
Normal file
37
tests/identityService.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { init } from '../src/cli/commands/init.js';
|
||||
import { loadConfig, saveConfig } from '../src/core/config.js';
|
||||
import { agentIdentity, resolveAgentName } from '../src/core/services/identityService.js';
|
||||
|
||||
describe('agent identity resolution', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-identity-'));
|
||||
init(cwd, { projectName: 'identity-test', yes: true });
|
||||
const config = loadConfig(cwd);
|
||||
config.agents = {
|
||||
claude: { role: 'architect', aliases: ['windows-claude'] },
|
||||
kimi: { role: 'implementer', aliases: ['kimi-ah'] },
|
||||
};
|
||||
config.roles.architect.preferredAgent = 'claude';
|
||||
config.roles.implementer.preferredAgent = 'kimi';
|
||||
saveConfig(cwd, config);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
|
||||
it('maps aliases and roles to the canonical roster agent', () => {
|
||||
expect(resolveAgentName(cwd, 'kimi-ah')).toBe('kimi');
|
||||
expect(resolveAgentName(cwd, 'implementer')).toBe('kimi');
|
||||
expect(resolveAgentName(cwd, 'architect')).toBe('claude');
|
||||
expect(agentIdentity(cwd, 'kimi').names).toEqual(new Set(['kimi', 'kimi-ah', 'implementer']));
|
||||
});
|
||||
|
||||
it('rejects an unknown recipient when a roster is configured', () => {
|
||||
expect(() => resolveAgentName(cwd, 'ghost-agent')).toThrow(/unknown agent, alias, or role/i);
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 ────────────
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user