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:
parent
7723360054
commit
961675ac40
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"description": "Local coordination layer for AI coding agents",
|
"description": "Local coordination layer for AI coding agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@ -113,7 +113,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
|
|||||||
export function createProgram(cwd: string): Command {
|
export function createProgram(cwd: string): Command {
|
||||||
const program = new Command('agenthub')
|
const program = new Command('agenthub')
|
||||||
.description('Local coordination layer for AI coding agents')
|
.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)');
|
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import Fastify from 'fastify';
|
|||||||
import { registerRoutes } from './routes.js';
|
import { registerRoutes } from './routes.js';
|
||||||
import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js';
|
import { resolveAdvertiseUrl, startDiscoveryBroadcaster } from '../discovery.js';
|
||||||
import { startEntityWatcher } from './fsWatch.js';
|
import { startEntityWatcher } from './fsWatch.js';
|
||||||
|
import { startStatusAutoRefresh } from './statusRefresh.js';
|
||||||
|
|
||||||
export function buildApp(cwd: string) {
|
export function buildApp(cwd: string) {
|
||||||
const app = Fastify({ logger: false });
|
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;
|
let broadcaster: ReturnType<typeof startDiscoveryBroadcaster> | undefined;
|
||||||
// Emit SSE events for CLI/file writes too, not just REST mutations.
|
// Emit SSE events for CLI/file writes too, not just REST mutations.
|
||||||
const stopWatcher = startEntityWatcher(cwd);
|
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 () => {
|
app.addHook('onClose', async () => {
|
||||||
broadcaster?.stop();
|
broadcaster?.stop();
|
||||||
stopWatcher();
|
stopWatcher();
|
||||||
|
stopStatusRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
40
src/server/statusRefresh.ts
Normal file
40
src/server/statusRefresh.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, rmSync } from 'fs';
|
import { mkdtempSync, rmSync, readFileSync } from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { buildApp } from '../src/server/index.js';
|
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');
|
expect(forTask[0].action).toBe('created');
|
||||||
}, 4000);
|
}, 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);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user