agenthub/src/server/fsWatch.ts
chahinebrini 6d088648da 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>
2026-07-29 17:48:24 +02:00

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);
}