diff --git a/package.json b/package.json index 9c5bba9..9b9eceb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "0.3.0", + "version": "0.3.1", "description": "Local coordination layer for AI coding agents", "type": "module", "main": "./dist/index.js", diff --git a/src/cli/index.ts b/src/cli/index.ts index 255119d..195349d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -113,7 +113,7 @@ async function runRemote(serverUrl: string, fn: () => Promise): Promise', 'AgentHub server URL (env: AGENTHUB_SERVER)'); program diff --git a/src/server/index.ts b/src/server/index.ts index c175ba7..d2367b6 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2,6 +2,7 @@ import Fastify from 'fastify'; import { registerRoutes } from './routes.js'; import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js'; import { startEntityWatcher } from './fsWatch.js'; +import { startStatusAutoRefresh } from './statusRefresh.js'; export function buildApp(cwd: string) { const app = Fastify({ logger: false }); @@ -17,9 +18,12 @@ export async function startServer(cwd: string, options: { port?: number; host?: let broadcaster: ReturnType | undefined; // Emit SSE events for CLI/file writes too, not just REST mutations. const stopWatcher = startEntityWatcher(cwd); + // Keep status/latest.md fresh on every change so agents never read a stale snapshot. + const stopStatusRefresh = startStatusAutoRefresh(cwd); app.addHook('onClose', async () => { broadcaster?.stop(); stopWatcher(); + stopStatusRefresh(); }); try { diff --git a/src/server/statusRefresh.ts b/src/server/statusRefresh.ts new file mode 100644 index 0000000..69f6fba --- /dev/null +++ b/src/server/statusRefresh.ts @@ -0,0 +1,40 @@ +import { eventBus } from './events.js'; +import type { AgentHubEvent } from './events.js'; +import { updateStatus } from '../core/services/statusService.js'; + +/** + * Keep `.agenthub/status/latest.md` fresh. Agents are told (golden rule #2) to + * read the status snapshot, but it was only regenerated on an explicit + * `status --update` — so it drifted stale and misled agents (e.g. showing an + * old task list). This subscribes to the in-process event bus and regenerates + * the snapshot on every entity change, from both the REST and filesystem-watch + * paths, debounced so a burst collapses to one write. + * + * Presence ('agent') events are ephemeral and don't affect status. Writing the + * snapshot lives in `.agenthub/status/` — outside the watched entity dirs — so + * it never feeds back into the watcher. + */ +const DEBOUNCE_MS = 300; + +export function startStatusAutoRefresh(cwd: string): () => void { + let timer: NodeJS.Timeout | undefined; + + const onChange = (event: AgentHubEvent) => { + if (event.type === 'agent') return; + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + try { + updateStatus(cwd); + } catch { + // best-effort — a failed status refresh must never break the server + } + }, DEBOUNCE_MS); + }; + + eventBus.on('change', onChange); + return () => { + if (timer) clearTimeout(timer); + eventBus.off('change', onChange); + }; +} diff --git a/tests/sse.test.ts b/tests/sse.test.ts index 5d46704..5235978 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'fs'; +import { mkdtempSync, rmSync, readFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { buildApp } from '../src/server/index.js'; @@ -531,3 +531,35 @@ describe('fsWatch dedup with the REST emit path', () => { expect(forTask[0].action).toBe('created'); }, 4000); }); + +// ─── 7. status auto-refresh: snapshot stays fresh after mutations ───────────── + +describe('status auto-refresh on mutation', () => { + let cwd: string; + let server: Awaited>; + + beforeEach(async () => { + cwd = mkdtempSync(join(tmpdir(), 'ah-status-refresh-')); + init(cwd, { projectName: 'status-refresh', yes: true }); + server = await startServer(cwd, { host: '127.0.0.1', port: 0 }); + }); + + afterEach(async () => { + await server.app.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('regenerates status/latest.md with the new task after POST /tasks', async () => { + await fetch(`${server.url}/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Fresh status task', role: 'implementer' }), + }); + + // Wait past the status-refresh debounce (300 ms) + write. + await new Promise((r) => setTimeout(r, 600)); + + const status = readFileSync(join(cwd, '.agenthub', 'status', 'latest.md'), 'utf-8'); + expect(status).toContain('TSK-0001'); // the just-created task shows as active + }, 4000); +});