feat(council-v2): role execution logging + SQLite busy_timeout

- store.ts: add PRAGMA busy_timeout = 5000 after WAL mode (both paths)
- topic-rule-adapter: accept optional logStore for role execution logs
  - role-started/completed/failed events written to logStore (not business store)
  - role errors caught gracefully, other actions continue
  - meta includes scope field for source identification
  - no logStore = no logging (graceful degradation)
- coding-task.ts: projection unchanged (role logs never enter business store)
- tests: 8 passing (echo+logStore, role-failed, no-logStore, Moore diff,
  baseline, coding lifecycle, rejection, log isolation)

小橘 🍊 (NEKO Team)
This commit is contained in:
2026-04-17 02:55:28 +00:00
parent e350bb371f
commit aaa144b877
5 changed files with 269 additions and 7 deletions
+4 -4
View File
@@ -996,16 +996,16 @@ export { buildTopicsFromEvents } from './topic.js';
export type { CodingTaskContext } from './topics/coding-task.js';
export { createCodingTaskType } from './topics/coding-task.js';
export { createTopicTicker } from './topics/index.js';
export { createArchitectRole } from './topics/roles/architect-llm.js';
export { createCoderRole } from './topics/roles/coder-cursor.js';
export { createReviewerRole } from './topics/roles/reviewer-cursor.js';
export type {
TopicRule,
TopicTickResult,
} from './topics/topic-rule-adapter.js';
export { createTopicRule } from './topics/topic-rule-adapter.js';
export type { TopicAction, TopicType } from './topics/topic-type.js';
export { createTopicTicker } from './topics/index.js';
export { createArchitectRole } from './topics/roles/architect-llm.js';
export { createCoderRole } from './topics/roles/coder-cursor.js';
export { createReviewerRole } from './topics/roles/reviewer-cursor.js';
// ── Executors ─────────────────────────────────────────────────
+2
View File
@@ -228,6 +228,7 @@ export function createStore(options: CreateStoreOptions): PulseStore {
const eventsDb = new Database(eventsDbPath, { create: true });
eventsDb.exec('PRAGMA journal_mode = WAL');
eventsDb.exec('PRAGMA busy_timeout = 5000');
eventsDb.exec(EVENTS_SCHEMA);
eventsDb.exec(OBJECTS_SCHEMA);
migrateEventsObjectId(eventsDb);
@@ -524,6 +525,7 @@ function openScopeDb(path: string): Database {
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path, { create: true });
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA busy_timeout = 5000');
db.exec(EVENTS_SCHEMA);
db.exec(OBJECTS_SCHEMA);
migrateEventsObjectId(db);
@@ -138,4 +138,47 @@ describe('CodingTask TopicType', () => {
const r7 = await rule.tick();
expect(r7.executed).toEqual([]);
});
it('role execution logs go to logStore, not business store', async () => {
setup();
const logTmpDir = mkdtempSync(join(tmpdir(), 'coding-log-'));
const logStore = createStore({
eventsDbPath: join(logTmpDir, 'log.db'),
objectsDir: join(logTmpDir, 'log-objects'),
});
try {
const codingTask = createCodingTaskType();
const rule = createTopicRule(codingTask, store, logStore);
store.appendEvent({
occurredAt: Date.now(),
kind: 'coding.created',
key: 'task-3',
meta: JSON.stringify({
topicId: 'task-3',
title: 'Test log isolation',
description: 'Role logs go to logStore',
repoDir: '/tmp/repo',
}),
});
// Tick: architect runs
await rule.tick();
// Role logs in logStore
expect(logStore.queryByKind('coding.role-started').length).toBe(1);
expect(logStore.queryByKind('coding.role-completed').length).toBe(1);
// No role logs in business store
expect(store.queryByKind('coding.role-started').length).toBe(0);
expect(store.queryByKind('coding.role-completed').length).toBe(0);
// Business event still written to store
expect(store.queryByKind('coding.analyzed').length).toBe(1);
} finally {
logStore.close();
rmSync(logTmpDir, { recursive: true, force: true });
}
});
});
@@ -14,6 +14,7 @@ import type { TopicAction, TopicType } from './topic-type.js';
describe('createTopicRule', () => {
let store: PulseStore;
let logStore: PulseStore;
let tmpDir: string;
function setup() {
@@ -22,12 +23,19 @@ describe('createTopicRule', () => {
eventsDbPath: join(tmpDir, 'test.db'),
objectsDir: join(tmpDir, 'objects'),
});
logStore = createStore({
eventsDbPath: join(tmpDir, 'log.db'),
objectsDir: join(tmpDir, 'log-objects'),
});
}
afterEach(() => {
try {
store?.close();
} catch {}
try {
logStore?.close();
} catch {}
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
});
@@ -82,7 +90,7 @@ describe('createTopicRule', () => {
},
};
const rule = createTopicRule(echoType, store);
const rule = createTopicRule(echoType, store, logStore);
// Create a topic
store.appendEvent({
@@ -99,6 +107,167 @@ describe('createTopicRule', () => {
// Second tick: echo already done, no new actions
const r2 = await rule.tick();
expect(r2.executed).toEqual([]);
// Verify role execution log events were written to logStore (not store)
const started = logStore.queryByKind('echo.role-started');
expect(started.length).toBe(1);
const startedMeta = JSON.parse(started[0].meta!);
expect(startedMeta).toEqual({ topicId: 't1', role: 'echo', scope: 'echo' });
const completed = logStore.queryByKind('echo.role-completed');
expect(completed.length).toBe(1);
const completedMeta = JSON.parse(completed[0].meta!);
expect(completedMeta.topicId).toBe('t1');
expect(completedMeta.role).toBe('echo');
expect(completedMeta.scope).toBe('echo');
expect(typeof completedMeta.durationMs).toBe('number');
// Verify no role log events in business store
expect(store.queryByKind('echo.role-started').length).toBe(0);
expect(store.queryByKind('echo.role-completed').length).toBe(0);
});
it('writes role-failed event when role throws, continues other actions', async () => {
setup();
interface Ctx {
topicId: string;
done: boolean;
}
const failType: TopicType<
Ctx,
{
bomb: (ctx: Ctx, store: PulseStore) => Promise<void>;
safe: (ctx: Ctx, store: PulseStore) => Promise<void>;
}
> = {
name: 'failtest',
projection: `
(
$created := $[kind = 'failtest.created'];
$merge($created.(
$m := $parse(meta);
{ $m.topicId: { "topicId": $m.topicId, "done": false } }
))
)
`,
roles: {
bomb: async () => {
throw new Error('kaboom');
},
safe: async (ctx, s) => {
s.appendEvent({
occurredAt: Date.now(),
kind: 'failtest.done',
key: ctx.topicId,
meta: JSON.stringify({ topicId: ctx.topicId }),
});
},
},
moderator: (_roles, topics) => {
const actions: TopicAction[] = [];
for (const [topicId] of Object.entries(topics)) {
actions.push({ topicId, role: 'bomb' });
actions.push({ topicId, role: 'safe' });
}
return actions;
},
};
const rule = createTopicRule(failType, store, logStore);
store.appendEvent({
occurredAt: Date.now(),
kind: 'failtest.created',
key: 't1',
meta: JSON.stringify({ topicId: 't1' }),
});
const r1 = await rule.tick();
// bomb fails so not in executed; safe succeeds
expect(r1.executed).toEqual([{ topicId: 't1', role: 'safe' }]);
// Verify role-failed event in logStore
const failed = logStore.queryByKind('failtest.role-failed');
expect(failed.length).toBe(1);
const failedMeta = JSON.parse(failed[0].meta!);
expect(failedMeta.role).toBe('bomb');
expect(failedMeta.error).toBe('kaboom');
expect(failedMeta.scope).toBe('failtest');
// Verify safe role completed in logStore
const safeCompleted = logStore.queryByKind('failtest.role-completed');
expect(safeCompleted.length).toBe(1);
});
it('skips logging when logStore is not provided', async () => {
setup();
interface EchoCtx {
topicId: string;
message: string;
echoed?: boolean;
}
const echoType: TopicType<
EchoCtx,
{ echo: (ctx: EchoCtx, store: PulseStore) => Promise<void> }
> = {
name: 'echo',
projection: `
(
$created := $[kind = 'echo.created'];
$echoed := $[kind = 'echo.echoed'];
$echoedIds := $distinct($echoed.($parse(meta).topicId));
$merge($created.(
$m := $parse(meta);
{ $m.topicId: {
"topicId": $m.topicId,
"message": $m.message,
"echoed": $m.topicId in $echoedIds
}}
))
)
`,
roles: {
echo: async (ctx, s) => {
s.appendEvent({
occurredAt: Date.now(),
kind: 'echo.echoed',
key: ctx.topicId,
meta: JSON.stringify({
topicId: ctx.topicId,
reply: `Echo: ${ctx.message}`,
}),
});
},
},
moderator: (_roles, topics) => {
const actions: TopicAction<'echo'>[] = [];
for (const [topicId, ctx] of Object.entries(topics)) {
if (!ctx.echoed) actions.push({ topicId, role: 'echo' });
}
return actions;
},
};
// No logStore — should work without logging
const rule = createTopicRule(echoType, store);
store.appendEvent({
occurredAt: Date.now(),
kind: 'echo.created',
key: 't1',
meta: JSON.stringify({ topicId: 't1', message: 'hello' }),
});
const r1 = await rule.tick();
expect(r1.executed).toEqual([{ topicId: 't1', role: 'echo' }]);
// No role log events in store
expect(store.queryByKind('echo.role-started').length).toBe(0);
expect(store.queryByKind('echo.role-completed').length).toBe(0);
});
it('Moore diff prevents re-execution of same action', async () => {
@@ -56,6 +56,7 @@ export interface TopicRule {
export function createTopicRule<TContext>(
topicType: TopicType<TContext, any>,
store: PulseStore,
logStore?: PulseStore,
): TopicRule {
// Moore machine state: snapshot baseline from last tick
let prevSnapshotJson = '';
@@ -112,8 +113,55 @@ export function createTopicRule<TContext>(
if (roleFn) {
const ctx = topics[action.topicId];
if (ctx !== undefined) {
await roleFn(ctx, store);
executed.push(action);
// Write role-started event to logStore if available
if (logStore) {
logStore.appendEvent({
occurredAt: Date.now(),
kind: `${topicType.name}.role-started`,
key: action.topicId,
meta: JSON.stringify({
topicId: action.topicId,
role: action.role,
scope: topicType.name,
}),
});
}
const start = Date.now();
try {
await roleFn(ctx, store);
// Write role-completed event to logStore if available
if (logStore) {
logStore.appendEvent({
occurredAt: Date.now(),
kind: `${topicType.name}.role-completed`,
key: action.topicId,
meta: JSON.stringify({
topicId: action.topicId,
role: action.role,
scope: topicType.name,
durationMs: Date.now() - start,
}),
});
}
executed.push(action);
} catch (err) {
// Write role-failed event to logStore if available (don't rethrow — let other actions continue)
if (logStore) {
logStore.appendEvent({
occurredAt: Date.now(),
kind: `${topicType.name}.role-failed`,
key: action.topicId,
meta: JSON.stringify({
topicId: action.topicId,
role: action.role,
scope: topicType.name,
durationMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
}),
});
}
}
}
}
}