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>
175 lines
6.7 KiB
TypeScript
175 lines
6.7 KiB
TypeScript
import { watch, mkdirSync, type FSWatcher } from 'node:fs';
|
|
import { basename } from 'node:path';
|
|
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.
|
|
*
|
|
* Closes the gap documented on the /events route: mutations made via the local
|
|
* CLI (direct file + SQLite writes, no --server) bypass the in-process event
|
|
* bus and were therefore invisible to SSE subscribers. This watcher observes
|
|
* the entity directories and emits the same AgentHubEvent for any change,
|
|
* regardless of which path produced it — so a watcher (e.g. a Windows client)
|
|
* is triggered automatically the instant a task is created, even from a plain
|
|
* `agenthub task create`.
|
|
*
|
|
* REST mutations also touch these files, so the watcher would double-emit; the
|
|
* dedup cache in events.ts (signature = type:id:stamp) suppresses the echo.
|
|
*/
|
|
|
|
const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
|
|
{ dir: 'tasks', type: 'task' },
|
|
{ dir: 'handoffs', type: 'handoff' },
|
|
{ dir: 'decisions', type: 'decision' },
|
|
{ dir: 'memory', type: 'memory' },
|
|
{ dir: 'messages', type: 'message' },
|
|
{ dir: 'asks', type: 'ask' },
|
|
];
|
|
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* Build the SSE event + dedup stamp from parsed frontmatter. Returns undefined
|
|
* if the file isn't a recognizable entity yet (e.g. caught mid-write).
|
|
*/
|
|
function toEvent(
|
|
type: AgentHubEventType,
|
|
fm: Record<string, unknown>,
|
|
): { event: AgentHubEvent; stamp: string | undefined } | undefined {
|
|
const id = str(fm.id);
|
|
if (!id) return undefined;
|
|
|
|
const createdAt = str(fm.createdAt);
|
|
const updatedAt = str(fm.updatedAt);
|
|
// Handoffs have only createdAt; for the rest, an updatedAt that differs from
|
|
// createdAt means a mutation of an existing entity -> 'updated'. This mirrors
|
|
// the POST=created / PATCH=updated semantics of the REST routes.
|
|
const stamp = updatedAt ?? createdAt;
|
|
const action: AgentHubEvent['action'] =
|
|
updatedAt && createdAt && updatedAt !== createdAt ? 'updated' : 'created';
|
|
|
|
switch (type) {
|
|
case 'task':
|
|
return {
|
|
stamp,
|
|
event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), claimedBy: str(fm.claimedBy), reviewer: str(fm.reviewer) },
|
|
};
|
|
case 'handoff':
|
|
return {
|
|
stamp,
|
|
event: { type, action: 'created', id, title: str(fm.summary), role: str(fm.toRole), assignedTo: str(fm.toAgent) },
|
|
};
|
|
case 'decision':
|
|
return { stamp, event: { type, action, id, title: str(fm.title) } };
|
|
case 'memory':
|
|
return { stamp, event: { type, action, id, title: str(fm.title) } };
|
|
case 'message':
|
|
return { stamp, event: { type, action, id, title: `${str(fm.from)} → ${str(fm.to)}`, status: str(fm.status), assignedTo: str(fm.to) } };
|
|
case 'ask':
|
|
return { stamp, event: { type, action, id, title: `${str(fm.from)} → ${str(fm.to)}`, status: str(fm.status), assignedTo: str(fm.to) } };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start watching the entity directories. Returns a stop function that tears
|
|
* down every watcher and pending timer. Safe to call in a project that hasn't
|
|
* created all entity directories yet — missing ones are simply skipped.
|
|
*/
|
|
export function startEntityWatcher(cwd: string): () => void {
|
|
const watchers: FSWatcher[] = [];
|
|
const timers = new Map<string, NodeJS.Timeout>();
|
|
|
|
for (const { dir, type } of WATCHED) {
|
|
const path = getEntityDir(cwd, dir);
|
|
|
|
try {
|
|
// Ensure the directory exists so the watcher attaches even before the
|
|
// first entity of this type is written (e.g. a fresh project's decisions).
|
|
mkdirSync(path, { recursive: true });
|
|
const watcher = watch(path, (_eventType, filename) => {
|
|
if (!filename) return; // some platforms omit the name; nothing to read
|
|
const name = basename(filename.toString());
|
|
if (!name.endsWith('.md')) return;
|
|
|
|
// Debounce per file so a rename+change burst reads the settled file once.
|
|
const key = `${type}:${name}`;
|
|
const existing = timers.get(key);
|
|
if (existing) clearTimeout(existing);
|
|
const filePath = getEntityDir(cwd, dir) + '/' + name;
|
|
const schedule = (attempt: number, delay: number) => {
|
|
timers.set(key, setTimeout(() => {
|
|
timers.delete(key);
|
|
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 {
|
|
// A directory that can't be watched (permissions, transient) shouldn't
|
|
// crash the server; the REST emit path still works for that entity.
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
for (const t of timers.values()) clearTimeout(t);
|
|
timers.clear();
|
|
for (const w of watchers) w.close();
|
|
};
|
|
}
|
|
|
|
function processFile(cwd: string, filePath: string, type: AgentHubEventType): Error | undefined {
|
|
let fm: Record<string, unknown>;
|
|
try {
|
|
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);
|
|
if (!built) return;
|
|
|
|
// If the REST path already emitted this exact revision, stay silent.
|
|
if (seenRecently(signatureOf(built.event.type, built.event.id, built.stamp))) return;
|
|
|
|
emitChange(built.event, built.stamp);
|
|
}
|