fix(server): auto-regenerate status snapshot on every change

The status snapshot (.agenthub/status/latest.md) was only refreshed on an
explicit `status --update`, so it drifted stale and misled agents that
follow golden rule #2 ("read status/latest.md") — e.g. an implementer
seeing an old task list and picking the wrong task.

- src/server/statusRefresh.ts: subscribe to the event bus and regenerate
  the snapshot on every task/handoff/decision/memory change (REST and
  filesystem-watch paths), debounced 300 ms. Presence ('agent') events
  are skipped; the snapshot lives outside the watched dirs so it can't
  feed back into the watcher.
- wired into startServer lifecycle (start + stop on close).
- test: status/latest.md reflects a new task shortly after POST /tasks.
  117/117 green.

Bump 0.3.0 -> 0.3.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-27 17:27:19 +02:00
parent 7723360054
commit 961675ac40
5 changed files with 79 additions and 3 deletions

View File

@ -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",

View File

@ -113,7 +113,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
export function createProgram(cwd: string): Command {
const program = new Command('agenthub')
.description('Local coordination layer for AI coding agents')
.version('0.3.0')
.version('0.3.1')
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
program

View File

@ -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<typeof startDiscoveryBroadcaster> | 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 {

View File

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

View File

@ -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<ReturnType<typeof startServer>>;
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);
});