From c7d011de25a0132b5ba4a55e9b6c3255678bbb74 Mon Sep 17 00:00:00 2001 From: Ame Date: Sun, 10 May 2026 19:34:54 +0800 Subject: [PATCH 01/12] feat(core): AgentWork primitive + ProviderResult.toolCalls plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `src/core/agent-work.ts` — the missing core primitive for "Alice does an async task outside chat". Trigger sources today (heartbeat / cron / task-router) and trigger sources tomorrow (factor mining, asset monitoring, ad-hoc scheduled DAGs) all share the same shape: take a payload, run the AI, optionally gate the notification, emit done/skip/error. AgentWork is that shape. API: class AgentWorkRunner { constructor({ agentCenter, connectorCenter, ... }) run(req: AgentWorkRequest, emit: EmitFn): Promise } AgentWorkRequest carries: prompt, session, preamble, metadata inputGate? (active-hours-style pre-AI guard) outputGate? (notify_user-style post-AI gate) onDelivered? emitNames + buildDonePayload + buildSkipPayload? + buildErrorPayload The runner is stateless — construct once at startup, call run() per request with the listener's per-call emit fn. Class form (rather than free function) keeps `src/core/` style consistent with AgentCenter / ConnectorCenter / NotificationsStore. Also surfaces `toolCalls` on `ProviderResult` (additive change). The existing pipeline already accumulates tool_use events as they stream through; AgentCenter now packages them into the final done event so AgentWork's outputGate can inspect "did the AI call notify_user?" without re-streaming. Test coverage: `src/core/agent-work.spec.ts` — 37 tests across: - default behaviour (no gates) — happy path - inputGate — null vs skip, AI-not-invoked, custom payload - outputGate — deliver/skip/probe inspection - notify_user-style tool inspection (load-bearing for heartbeat) - AI invocation errors — throw, non-Error, emit failure - notify failure — done with delivered=false, hook not called - onDelivered hook — called/not-called/throws - clock injection — durationMs honors injected now() - source label flow-through - concurrent runs (stateless runner) Followup commits migrate cron / task-router / heartbeat to use this primitive; this commit is just the primitive + plumbing, no consumers yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ai-providers/types.ts | 13 + src/core/agent-center.ts | 10 +- src/core/agent-work.spec.ts | 625 ++++++++++++++++++++++++++++++++++++ src/core/agent-work.ts | 297 +++++++++++++++++ 4 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 src/core/agent-work.spec.ts create mode 100644 src/core/agent-work.ts diff --git a/src/ai-providers/types.ts b/src/ai-providers/types.ts index fcd5c730..046894fc 100644 --- a/src/ai-providers/types.ts +++ b/src/ai-providers/types.ts @@ -14,10 +14,23 @@ export type ProviderEvent = // ==================== Types ==================== +/** A tool the AI invoked during this generation. Captured by AgentCenter + * as `tool_use` events stream through the pipeline. Used by AgentWork's + * outputGate to detect intent-signal tools like `notify_user`. */ +export interface ToolCallSummary { + id: string + name: string + input: unknown +} + export interface ProviderResult { text: string media: MediaAttachment[] mediaUrls?: string[] + /** Tool calls observed during this generation, in invocation order. + * AgentCenter populates this when it synthesizes the final done event; + * individual providers don't need to fill it themselves. */ + toolCalls?: ReadonlyArray } // ==================== GenerateOpts ==================== diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index e132a605..bb2a5598 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -13,6 +13,7 @@ */ import type { AskOptions, ProviderResult, ProviderEvent, GenerateOpts } from './ai-provider-manager.js' +import type { ToolCallSummary } from '../ai-providers/types.js' import type { ResolvedProfile } from './config.js' import { GenerateRouter, StreamableResult } from './ai-provider-manager.js' import { resolveProfile, resolveCredential } from './config.js' @@ -131,6 +132,11 @@ export class AgentCenter { let currentAssistantBlocks: ContentBlock[] = [] let currentUserBlocks: ContentBlock[] = [] let finalResult: ProviderResult | null = null + // Tool calls observed during this generation, captured for the final + // done event so AgentWork (and any other consumer awaiting the + // ProviderResult) can inspect what the AI invoked without having to + // re-stream the events themselves. + const toolCalls: ToolCallSummary[] = [] for await (const event of source) { switch (event.type) { @@ -143,6 +149,7 @@ export class AgentCenter { // Unified logging — all providers get this now logToolCall(event.name, event.input) this.toolCallLog?.start(event.id, event.name, event.input, session.id) + toolCalls.push({ id: event.id, name: event.name, input: event.input }) currentAssistantBlocks.push({ type: 'tool_use', id: event.id, @@ -227,7 +234,7 @@ export class AgentCenter { ] await session.appendAssistant(finalBlocks, provider.providerTag) - // 9. Yield done with merged media + // 9. Yield done with merged media + observed tool calls const mediaUrls = mediaBlocks.map(b => (b as { type: 'image'; url: string }).url) yield { type: 'done', @@ -235,6 +242,7 @@ export class AgentCenter { text: finalResult.text, media: allMedia, mediaUrls, + toolCalls, }, } } diff --git a/src/core/agent-work.spec.ts b/src/core/agent-work.spec.ts new file mode 100644 index 00000000..83894d02 --- /dev/null +++ b/src/core/agent-work.spec.ts @@ -0,0 +1,625 @@ +/** + * AgentWork — comprehensive coverage of the runner's pipeline behaviour. + * + * The runner is the load-bearing primitive for every "Alice does an + * async task outside chat" path (heartbeat / cron / task-router / + * future async triggers). Test coverage here is intentionally thorough: + * gate combinations, error paths, tool-call observation, hook + * misbehaviour. Trigger-source-specific tests (active-hours, dedup, + * STATUS replacement) live in the heartbeat / cron / task-router spec + * files; this file exercises the abstraction itself. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { AgentWorkRunner, type AgentWorkRequest, type AgentWorkEmitFn } from './agent-work.js' +import type { AgentCenter } from './agent-center.js' +import { ConnectorCenter } from './connector-center.js' +import { createMemoryNotificationsStore, type INotificationsStore } from './notifications-store.js' +import type { ProviderResult, ToolCallSummary } from '../ai-providers/types.js' + +// ==================== Helpers ==================== + +interface AgentCenterMock { + askWithSession: ReturnType + setResult(result: Partial): void + setShouldThrow(err: Error | null): void + callCount(): number + lastCall(): { prompt: string; preamble: string | undefined } | null +} + +/** Mocks AgentCenter.askWithSession with a Promise-shaped return. + * We exploit StreamableResult's PromiseLike contract — the runner + * awaits the result, so a plain Promise mock satisfies it. */ +function createMockAgentCenter(): AgentCenterMock { + let result: ProviderResult = { text: 'mock reply', media: [] } + let shouldThrow: Error | null = null + const calls: Array<{ prompt: string; preamble: string | undefined }> = [] + + const askWithSession = vi.fn(async (prompt: string, _session: unknown, opts?: { historyPreamble?: string }) => { + calls.push({ prompt, preamble: opts?.historyPreamble }) + if (shouldThrow) throw shouldThrow + return result + }) + + return { + askWithSession, + setResult(next) { result = { text: 'mock reply', media: [], ...next } }, + setShouldThrow(err) { shouldThrow = err }, + callCount() { return calls.length }, + lastCall() { return calls[calls.length - 1] ?? null }, + } +} + +/** Records emit() calls for assertion. */ +function createEmitRecorder() { + const events: Array<{ type: string; payload: object }> = [] + const emit: AgentWorkEmitFn = async (type, payload) => { + events.push({ type, payload }) + return { seq: events.length, ts: Date.now() } + } + return { events, emit } +} + +/** Minimal request factory — overlay caller-specific fields onto sane defaults. */ +function makeRequest(overrides: Partial = {}): AgentWorkRequest { + return { + prompt: 'do something', + session: { id: 'test/session' } as never, // session not introspected by runner; fake is fine + preamble: 'You are operating in test context.', + metadata: { source: 'cron' }, + emitNames: { done: 'cron.done', skip: 'cron.skip', error: 'cron.error' }, + buildDonePayload: (req, result, durationMs, delivered) => ({ + reply: result.text, + durationMs, + delivered, + }), + buildErrorPayload: (req, err, durationMs) => ({ + error: err.message, + durationMs, + }), + ...overrides, + } +} + +function createRunner(agentCenter: AgentCenterMock, store?: INotificationsStore) { + const notificationsStore = store ?? createMemoryNotificationsStore() + const connectorCenter = new ConnectorCenter({ notificationsStore }) + const logger = { warn: vi.fn(), error: vi.fn() } + const runner = new AgentWorkRunner({ + agentCenter: agentCenter as unknown as AgentCenter, + connectorCenter, + logger, + }) + return { runner, connectorCenter, notificationsStore, logger } +} + +// ==================== Tests ==================== + +describe('AgentWorkRunner — default behaviour (no gates)', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let store: INotificationsStore + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + store = made.notificationsStore + emitRec = createEmitRecorder() + }) + + it('invokes AI with the prompt + preamble', async () => { + await runner.run(makeRequest({ prompt: 'hello', preamble: 'context X' }), emitRec.emit) + expect(mock.callCount()).toBe(1) + expect(mock.lastCall()?.prompt).toBe('hello') + expect(mock.lastCall()?.preamble).toBe('context X') + }) + + it('delivers result.text via connectorCenter.notify', async () => { + mock.setResult({ text: 'AI says hi' }) + await runner.run(makeRequest(), emitRec.emit) + const { entries } = await store.read() + expect(entries).toHaveLength(1) + expect(entries[0].text).toBe('AI says hi') + expect(entries[0].source).toBe('cron') + }) + + it('emits done with delivered=true and durationMs >= 0', async () => { + mock.setResult({ text: 'reply' }) + await runner.run(makeRequest(), emitRec.emit) + expect(emitRec.events).toHaveLength(1) + expect(emitRec.events[0].type).toBe('cron.done') + const payload = emitRec.events[0].payload as { reply: string; durationMs: number; delivered: boolean } + expect(payload.reply).toBe('reply') + expect(payload.delivered).toBe(true) + expect(payload.durationMs).toBeGreaterThanOrEqual(0) + }) + + it('does not emit skip or error on the happy path', async () => { + await runner.run(makeRequest(), emitRec.emit) + expect(emitRec.events.filter(e => e.type === 'cron.skip')).toHaveLength(0) + expect(emitRec.events.filter(e => e.type === 'cron.error')).toHaveLength(0) + }) + + it('returns outcome=delivered', async () => { + const result = await runner.run(makeRequest(), emitRec.emit) + expect(result.outcome).toBe('delivered') + }) +}) + +describe('AgentWorkRunner — inputGate', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let store: INotificationsStore + let logger: { warn: ReturnType; error: ReturnType } + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + store = made.notificationsStore + logger = made.logger + emitRec = createEmitRecorder() + }) + + it('returns null → AI is invoked', async () => { + await runner.run( + makeRequest({ inputGate: () => null }), + emitRec.emit, + ) + expect(mock.callCount()).toBe(1) + }) + + it('returns skip → AI is NOT invoked', async () => { + await runner.run( + makeRequest({ + inputGate: () => ({ reason: 'outside-hours', payload: { reason: 'outside-hours' } }), + }), + emitRec.emit, + ) + expect(mock.callCount()).toBe(0) + }) + + it('skip → emits skip event with skip.payload', async () => { + await runner.run( + makeRequest({ + inputGate: () => ({ reason: 'outside-hours', payload: { reason: 'outside-hours', detail: 'asia/tokyo' } }), + }), + emitRec.emit, + ) + expect(emitRec.events).toHaveLength(1) + expect(emitRec.events[0].type).toBe('cron.skip') + expect(emitRec.events[0].payload).toEqual({ reason: 'outside-hours', detail: 'asia/tokyo' }) + }) + + it('skip → outcome=skipped with skipReason set', async () => { + const result = await runner.run( + makeRequest({ + inputGate: () => ({ reason: 'outside-hours', payload: {} }), + }), + emitRec.emit, + ) + expect(result.outcome).toBe('skipped') + expect(result.skipReason).toBe('outside-hours') + }) + + it('skip → no notification appended', async () => { + await runner.run( + makeRequest({ + inputGate: () => ({ reason: 'gated', payload: {} }), + }), + emitRec.emit, + ) + const { entries } = await store.read() + expect(entries).toHaveLength(0) + }) + + it('skip → buildSkipPayload override is used when provided', async () => { + await runner.run( + makeRequest({ + inputGate: () => ({ reason: 'gated', payload: { from: 'gate' } }), + buildSkipPayload: (_req, skip) => ({ reason: skip.reason, customField: 'override' }), + }), + emitRec.emit, + ) + expect(emitRec.events[0].payload).toEqual({ reason: 'gated', customField: 'override' }) + }) + + it('skip but emitNames.skip undefined → silent suppression with warning', async () => { + await runner.run( + makeRequest({ + emitNames: { done: 'cron.done', error: 'cron.error' }, // no skip + inputGate: () => ({ reason: 'gated', payload: {} }), + }), + emitRec.emit, + ) + expect(emitRec.events).toHaveLength(0) // nothing emitted + expect(logger.warn).toHaveBeenCalled() + expect(logger.warn.mock.calls[0][0]).toContain('skip=') + }) +}) + +describe('AgentWorkRunner — outputGate', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let store: INotificationsStore + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + store = made.notificationsStore + emitRec = createEmitRecorder() + }) + + it('default (omitted) delivers result.text', async () => { + mock.setResult({ text: 'untouched reply' }) + await runner.run(makeRequest(), emitRec.emit) + const { entries } = await store.read() + expect(entries[0].text).toBe('untouched reply') + }) + + it('deliver decision uses the gate text not result.text', async () => { + mock.setResult({ text: 'raw AI text' }) + await runner.run( + makeRequest({ + outputGate: () => ({ kind: 'deliver', text: 'rewritten by gate', media: [] }), + }), + emitRec.emit, + ) + const { entries } = await store.read() + expect(entries[0].text).toBe('rewritten by gate') + }) + + it('skip decision → emits skip event with reason, no notify', async () => { + await runner.run( + makeRequest({ + outputGate: () => ({ kind: 'skip', reason: 'duplicate', payload: { reason: 'duplicate' } }), + }), + emitRec.emit, + ) + expect(emitRec.events.find(e => e.type === 'cron.skip')).toBeDefined() + expect(emitRec.events.find(e => e.type === 'cron.done')).toBeUndefined() + const { entries } = await store.read() + expect(entries).toHaveLength(0) + }) + + it('skip decision → outcome=skipped with reason', async () => { + const result = await runner.run( + makeRequest({ + outputGate: () => ({ kind: 'skip', reason: 'duplicate', payload: {} }), + }), + emitRec.emit, + ) + expect(result.outcome).toBe('skipped') + expect(result.skipReason).toBe('duplicate') + }) + + it('receives probe with text, media, toolCalls', async () => { + const observed: Array<{ text: string; mediaLen: number; toolCallCount: number }> = [] + mock.setResult({ + text: 'AI text', + media: [{ type: 'image', path: '/tmp/x.png' }], + toolCalls: [{ id: 't1', name: 'foo', input: { x: 1 } }], + }) + await runner.run( + makeRequest({ + outputGate: (probe) => { + observed.push({ + text: probe.text, + mediaLen: probe.media.length, + toolCallCount: probe.toolCalls.length, + }) + return { kind: 'deliver', text: probe.text, media: probe.media } + }, + }), + emitRec.emit, + ) + expect(observed).toHaveLength(1) + expect(observed[0]).toEqual({ text: 'AI text', mediaLen: 1, toolCallCount: 1 }) + }) + + it('toolCalls undefined in result → probe gets empty array', async () => { + let observedLen = -1 + mock.setResult({ text: 'reply' /* no toolCalls */ }) + await runner.run( + makeRequest({ + outputGate: (probe) => { + observedLen = probe.toolCalls.length + return { kind: 'deliver', text: probe.text, media: probe.media } + }, + }), + emitRec.emit, + ) + expect(observedLen).toBe(0) + }) +}) + +describe('AgentWorkRunner — notify_user-style tool inspection', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let store: INotificationsStore + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + store = made.notificationsStore + emitRec = createEmitRecorder() + }) + + /** The reference outputGate shape that heartbeat will use to replace + * the STATUS regex protocol. Tested here so the AgentWork primitive + * guarantees this idiom keeps working. */ + function notifyUserGate(probe: { text: string; media: unknown[]; toolCalls: ReadonlyArray }) { + const call = probe.toolCalls.find((c) => c.name === 'notify_user') + if (!call) return { kind: 'skip' as const, reason: 'ack', payload: { reason: 'ack' } } + const text = ((call.input ?? {}) as { text?: string }).text ?? '' + if (!text.trim()) return { kind: 'skip' as const, reason: 'empty', payload: { reason: 'empty' } } + return { kind: 'deliver' as const, text, media: probe.media as never } + } + + it('AI calls notify_user → delivers tool args, not result.text', async () => { + mock.setResult({ + text: 'I have decided to notify the user', // raw AI text — should NOT be delivered + toolCalls: [{ id: 't1', name: 'notify_user', input: { text: 'BTC dropped 5%', urgency: 'important' } }], + }) + await runner.run(makeRequest({ outputGate: notifyUserGate }), emitRec.emit) + const { entries } = await store.read() + expect(entries).toHaveLength(1) + expect(entries[0].text).toBe('BTC dropped 5%') + }) + + it('AI does not call notify_user → skip with reason=ack', async () => { + mock.setResult({ text: 'nothing to report', toolCalls: [] }) + const result = await runner.run(makeRequest({ outputGate: notifyUserGate }), emitRec.emit) + expect(result.skipReason).toBe('ack') + expect((await store.read()).entries).toHaveLength(0) + }) + + it('AI calls notify_user with empty text → skip reason=empty', async () => { + mock.setResult({ + text: '', + toolCalls: [{ id: 't1', name: 'notify_user', input: { text: ' ' } }], + }) + const result = await runner.run(makeRequest({ outputGate: notifyUserGate }), emitRec.emit) + expect(result.skipReason).toBe('empty') + }) +}) + +describe('AgentWorkRunner — AI invocation errors', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let store: INotificationsStore + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + store = made.notificationsStore + emitRec = createEmitRecorder() + }) + + it('AI throws → emits error event with caller-shaped payload', async () => { + mock.setShouldThrow(new Error('engine boom')) + await runner.run(makeRequest(), emitRec.emit) + expect(emitRec.events).toHaveLength(1) + expect(emitRec.events[0].type).toBe('cron.error') + expect(emitRec.events[0].payload).toMatchObject({ error: 'engine boom' }) + }) + + it('AI throws → outcome=errored', async () => { + mock.setShouldThrow(new Error('boom')) + const result = await runner.run(makeRequest(), emitRec.emit) + expect(result.outcome).toBe('errored') + }) + + it('AI throws → no notification appended', async () => { + mock.setShouldThrow(new Error('boom')) + await runner.run(makeRequest(), emitRec.emit) + expect((await store.read()).entries).toHaveLength(0) + }) + + it('AI throws → no done event emitted', async () => { + mock.setShouldThrow(new Error('boom')) + await runner.run(makeRequest(), emitRec.emit) + expect(emitRec.events.find(e => e.type === 'cron.done')).toBeUndefined() + }) + + it('AI throws non-Error → wraps in Error', async () => { + mock.setShouldThrow('string error' as unknown as Error) + await runner.run(makeRequest(), emitRec.emit) + expect(emitRec.events[0].payload).toMatchObject({ error: 'string error' }) + }) + + it('error event emit failure is logged, run does not throw', async () => { + mock.setShouldThrow(new Error('boom')) + const made = createRunner(mock) + const flakyEmit: AgentWorkEmitFn = vi.fn(async () => { + throw new Error('emit fail') + }) + // Should NOT throw despite emit failing + const result = await made.runner.run(makeRequest(), flakyEmit) + expect(result.outcome).toBe('errored') + expect(made.logger.error).toHaveBeenCalled() + }) +}) + +describe('AgentWorkRunner — notify failure', () => { + let mock: AgentCenterMock + let store: INotificationsStore + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + store = createMemoryNotificationsStore() + emitRec = createEmitRecorder() + }) + + it('notify throw → done event emitted with delivered=false', async () => { + // Inject a connectorCenter whose notify throws + const connectorCenter = new ConnectorCenter({ notificationsStore: store }) + vi.spyOn(connectorCenter, 'notify').mockRejectedValue(new Error('notify boom')) + const logger = { warn: vi.fn(), error: vi.fn() } + const runner = new AgentWorkRunner({ + agentCenter: mock as unknown as AgentCenter, + connectorCenter, + logger, + }) + + await runner.run(makeRequest(), emitRec.emit) + + const doneEvent = emitRec.events.find(e => e.type === 'cron.done') + expect(doneEvent).toBeDefined() + expect((doneEvent!.payload as { delivered: boolean }).delivered).toBe(false) + expect(logger.warn).toHaveBeenCalled() + }) + + it('notify throw → outcome still delivered (work itself succeeded)', async () => { + const connectorCenter = new ConnectorCenter({ notificationsStore: store }) + vi.spyOn(connectorCenter, 'notify').mockRejectedValue(new Error('notify boom')) + const runner = new AgentWorkRunner({ + agentCenter: mock as unknown as AgentCenter, + connectorCenter, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + const result = await runner.run(makeRequest(), emitRec.emit) + expect(result.outcome).toBe('delivered') + }) + + it('notify throw → onDelivered NOT called', async () => { + const connectorCenter = new ConnectorCenter({ notificationsStore: store }) + vi.spyOn(connectorCenter, 'notify').mockRejectedValue(new Error('notify boom')) + const runner = new AgentWorkRunner({ + agentCenter: mock as unknown as AgentCenter, + connectorCenter, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + const onDelivered = vi.fn() + await runner.run(makeRequest({ onDelivered }), emitRec.emit) + expect(onDelivered).not.toHaveBeenCalled() + }) +}) + +describe('AgentWorkRunner — onDelivered hook', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let logger: { warn: ReturnType; error: ReturnType } + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + logger = made.logger + emitRec = createEmitRecorder() + }) + + it('called with delivered text after successful notify', async () => { + mock.setResult({ text: 'hello' }) + const onDelivered = vi.fn() + await runner.run(makeRequest({ onDelivered }), emitRec.emit) + expect(onDelivered).toHaveBeenCalledTimes(1) + expect(onDelivered.mock.calls[0][0]).toBe('hello') + }) + + it('NOT called when outputGate skips', async () => { + const onDelivered = vi.fn() + await runner.run( + makeRequest({ + onDelivered, + outputGate: () => ({ kind: 'skip', reason: 'duplicate', payload: {} }), + }), + emitRec.emit, + ) + expect(onDelivered).not.toHaveBeenCalled() + }) + + it('NOT called when inputGate skips', async () => { + const onDelivered = vi.fn() + await runner.run( + makeRequest({ + onDelivered, + inputGate: () => ({ reason: 'gated', payload: {} }), + }), + emitRec.emit, + ) + expect(onDelivered).not.toHaveBeenCalled() + }) + + it('throw is caught, run completes, done emitted', async () => { + const onDelivered = vi.fn(() => { throw new Error('hook boom') }) + const result = await runner.run(makeRequest({ onDelivered }), emitRec.emit) + expect(result.outcome).toBe('delivered') + expect(emitRec.events.find(e => e.type === 'cron.done')).toBeDefined() + expect(logger.warn).toHaveBeenCalled() + }) +}) + +describe('AgentWorkRunner — clock injection', () => { + it('durationMs uses injected now()', async () => { + const mock = createMockAgentCenter() + const store = createMemoryNotificationsStore() + const connectorCenter = new ConnectorCenter({ notificationsStore: store }) + let t = 1000 + const runner = new AgentWorkRunner({ + agentCenter: mock as unknown as AgentCenter, + connectorCenter, + now: () => { + const v = t + t += 250 // every call advances by 250ms + return v + }, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + const emitRec = createEmitRecorder() + await runner.run(makeRequest(), emitRec.emit) + const done = emitRec.events.find(e => e.type === 'cron.done')! + // start=1000, end=1250 → duration 250 + expect((done.payload as { durationMs: number }).durationMs).toBe(250) + }) +}) + +describe('AgentWorkRunner — source label flows through', () => { + it('connectorCenter receives metadata.source as the source label', async () => { + const mock = createMockAgentCenter() + const store = createMemoryNotificationsStore() + const connectorCenter = new ConnectorCenter({ notificationsStore: store }) + const notifySpy = vi.spyOn(connectorCenter, 'notify') + const runner = new AgentWorkRunner({ + agentCenter: mock as unknown as AgentCenter, + connectorCenter, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + const emitRec = createEmitRecorder() + await runner.run(makeRequest({ metadata: { source: 'heartbeat' } }), emitRec.emit) + expect(notifySpy).toHaveBeenCalledWith( + 'mock reply', + expect.objectContaining({ source: 'heartbeat' }), + ) + }) +}) + +describe('AgentWorkRunner — concurrent runs (stateless runner)', () => { + it('two parallel run() calls do not interfere', async () => { + const mock = createMockAgentCenter() + const made = createRunner(mock) + const emit1 = createEmitRecorder() + const emit2 = createEmitRecorder() + const [r1, r2] = await Promise.all([ + made.runner.run(makeRequest({ prompt: 'A', metadata: { source: 'cron' } }), emit1.emit), + made.runner.run(makeRequest({ prompt: 'B', metadata: { source: 'task' } }), emit2.emit), + ]) + expect(r1.outcome).toBe('delivered') + expect(r2.outcome).toBe('delivered') + expect(mock.callCount()).toBe(2) + // Each emit recorder got its own done event + expect(emit1.events).toHaveLength(1) + expect(emit2.events).toHaveLength(1) + }) +}) diff --git a/src/core/agent-work.ts b/src/core/agent-work.ts new file mode 100644 index 00000000..c538e5eb --- /dev/null +++ b/src/core/agent-work.ts @@ -0,0 +1,297 @@ +/** + * AgentWork — core primitive for "Alice does an async task outside chat". + * + * The shape: a piece of work has a `prompt` (what to do), a `session` + * (continuity), a `preamble` (context hint), optional input/output + * gates, and a set of event names to emit on completion. + * + * The runner threads it through: + * inputGate → AI call → outputGate → notify → emit + * + * Three trigger sources today consume this primitive — heartbeat (with + * active-hours inputGate + notify_user-inspecting outputGate + dedup + * onDelivered), cron (no gates, default delivery), task-router (same). + * Future sources (factor mining, asset monitoring, etc.) plug in + * without re-implementing the gate→AI→notify→emit pipeline. + * + * The runner itself is stateless — construct once at startup with + * shared deps, call run() per request with the per-call emit fn from + * the listener context. + */ + +import type { AgentCenter } from './agent-center.js' +import type { ConnectorCenter } from './connector-center.js' +import type { ISessionStore } from './session.js' +import type { NotificationSource } from './notifications-store.js' +import type { ProviderResult, ToolCallSummary } from '../ai-providers/types.js' +import type { MediaAttachment } from './types.js' + +// ==================== Request / Result types ==================== + +/** Probe handed to outputGate — combines AI text/media plus the tool + * calls observed during generation. The latter is the mechanism by + * which structured tools like `notify_user` signal intent. */ +export interface AgentWorkResultProbe { + text: string + media: MediaAttachment[] + toolCalls: ReadonlyArray +} + +/** Skip decision — emitted as the configured `skip` event. */ +export interface AgentWorkSkip { + reason: string + /** Caller-shaped payload for the skip event. Free-form so each trigger + * source can attach its own metadata (parsedReason, reason-detail, …). */ + payload: object +} + +/** Output gate decision — deliver to the user or skip silently. */ +export type OutputGateDecision = + | { kind: 'deliver'; text: string; media: MediaAttachment[] } + | { kind: 'skip'; reason: string; payload: object } + +export interface AgentWorkRequest { + /** What Alice is asked to do (the AI prompt). */ + prompt: string + + /** Conversation scope. Same SessionStore reused across submissions + * from the same trigger source = continuous conversation. */ + session: ISessionStore + + /** Pre-prompt context — passed to agentCenter.askWithSession via + * AskOptions.historyPreamble. */ + preamble: string + + /** Used as connectorCenter.notify source label, plus available to + * gate functions for trigger-specific decisions. The source must + * match the NotificationSource union; adding a new trigger source + * means widening that union in notifications-store.ts. */ + metadata: { source: NotificationSource; [k: string]: unknown } + + /** Pre-AI guard. Return non-null to short-circuit (skip event emitted, + * AI never invoked). Used by heartbeat for active-hours filtering; + * used by cron's listener for own-job filtering (though that filter + * lives outside the request — pre-listener-handle, since it gates + * whether to even build the request). */ + inputGate?: (req: AgentWorkRequest) => AgentWorkSkip | null + + /** Post-AI gate. Decides notify vs skip based on AI result + observed + * tool calls. Default behaviour: deliver result.text unconditionally + * (matches today's cron / task-router semantics). */ + outputGate?: (probe: AgentWorkResultProbe, req: AgentWorkRequest) => OutputGateDecision + + /** Bookkeeping callback after a successful delivery — used by heartbeat + * to record the dedup window state. Not called for skip / error. */ + onDelivered?: (text: string, req: AgentWorkRequest) => void + + /** Names of the events this work emits. + * - done: on successful delivery (always) + * - error: on AI invocation throw (always) + * - skip: on inputGate / outputGate skip — REQUIRED if either gate + * can return a skip; the runner treats a missing skip name + * as a programming error and falls back to silent suppression. */ + emitNames: { done: string; skip?: string; error: string } + + /** Construct the payload for the `done` event. Caller-shaped so each + * trigger source can include its own identifiers (jobId, prompt, etc.). */ + buildDonePayload: ( + req: AgentWorkRequest, + result: ProviderResult, + durationMs: number, + delivered: boolean, + ) => object + + /** Construct the payload for the `skip` event. Defaults to the skip + * decision's `payload` field if not supplied. */ + buildSkipPayload?: (req: AgentWorkRequest, skip: AgentWorkSkip) => object + + /** Construct the payload for the `error` event. */ + buildErrorPayload: (req: AgentWorkRequest, err: Error, durationMs: number) => object +} + +export interface AgentWorkRunResult { + outcome: 'delivered' | 'skipped' | 'errored' + durationMs: number + /** When `outcome === 'skipped'`, the reason that was attached to the + * skip event. Useful for callers that want to do bookkeeping outside + * the event log. */ + skipReason?: string +} + +export interface AgentWorkRunnerDeps { + agentCenter: AgentCenter + connectorCenter: ConnectorCenter + /** Inject the wall clock for tests. */ + now?: () => number + /** Inject a logger for tests; defaults to console. */ + logger?: Pick +} + +/** The emit function shape — a permissive superset of the typed + * ListenerContext.emit signatures so the runner can accept any + * listener's emit without entangling its type generics. The caller + * is responsible for passing emit names that match its declared + * emits set; the runner doesn't validate. */ +export type AgentWorkEmitFn = ( + type: string, + payload: object, +) => Promise + +// ==================== Runner ==================== + +/** Stateless executor for AgentWork requests. Construct once with + * shared deps; call run(req, emit) per work submission. + * + * Class form (rather than free function) for parity with AgentCenter / + * ConnectorCenter / NotificationsStore — keeps `src/core/` style + * consistent and gives a stable hook point for future deps injection + * (rate limiting, observability, etc.) without API churn at call sites. */ +export class AgentWorkRunner { + private readonly agentCenter: AgentCenter + private readonly connectorCenter: ConnectorCenter + private readonly now: () => number + private readonly logger: Pick + + constructor(deps: AgentWorkRunnerDeps) { + this.agentCenter = deps.agentCenter + this.connectorCenter = deps.connectorCenter + this.now = deps.now ?? Date.now + this.logger = deps.logger ?? console + } + + async run( + req: AgentWorkRequest, + emit: AgentWorkEmitFn, + ): Promise { + const startMs = this.now() + + // ---- 1. inputGate ------------------------------------------------ + const skipBeforeAI = req.inputGate?.(req) + if (skipBeforeAI) { + await this.emitSkip(req, skipBeforeAI, emit) + return { + outcome: 'skipped', + durationMs: this.now() - startMs, + skipReason: skipBeforeAI.reason, + } + } + + // ---- 2. AI invocation ------------------------------------------- + let result: ProviderResult + try { + result = await this.agentCenter.askWithSession(req.prompt, req.session, { + historyPreamble: req.preamble, + }) + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)) + const durationMs = this.now() - startMs + try { + await emit( + req.emitNames.error, + req.buildErrorPayload(req, e, durationMs), + ) + } catch (emitErr) { + this.logger.error( + `agent-work[${req.metadata.source}]: emit error event failed:`, + emitErr, + ) + } + return { outcome: 'errored', durationMs } + } + + // ---- 3. outputGate ---------------------------------------------- + const probe: AgentWorkResultProbe = { + text: result.text, + media: result.media, + toolCalls: result.toolCalls ?? [], + } + const decision: OutputGateDecision = req.outputGate + ? req.outputGate(probe, req) + : { kind: 'deliver', text: result.text, media: result.media } + + if (decision.kind === 'skip') { + await this.emitSkip( + req, + { reason: decision.reason, payload: decision.payload }, + emit, + ) + return { + outcome: 'skipped', + durationMs: this.now() - startMs, + skipReason: decision.reason, + } + } + + // ---- 4. Notify -------------------------------------------------- + let delivered = false + try { + await this.connectorCenter.notify(decision.text, { + media: decision.media, + source: req.metadata.source, + }) + delivered = true + } catch (sendErr) { + // notify failure isn't fatal to the work — log and continue; + // the done event flag tells consumers whether the user actually + // got a push. Connectors that surface notifications make their + // own delivery decisions downstream. + this.logger.warn( + `agent-work[${req.metadata.source}]: notify failed:`, + sendErr, + ) + } + + // ---- 5. onDelivered hook --------------------------------------- + if (delivered) { + try { + req.onDelivered?.(decision.text, req) + } catch (hookErr) { + // Hook failure shouldn't lose the done event; log and proceed. + this.logger.warn( + `agent-work[${req.metadata.source}]: onDelivered hook threw:`, + hookErr, + ) + } + } + + // ---- 6. Emit done ---------------------------------------------- + const durationMs = this.now() - startMs + try { + await emit( + req.emitNames.done, + req.buildDonePayload(req, result, durationMs, delivered), + ) + } catch (emitErr) { + this.logger.error( + `agent-work[${req.metadata.source}]: emit done event failed:`, + emitErr, + ) + } + + return { outcome: 'delivered', durationMs } + } + + private async emitSkip( + req: AgentWorkRequest, + skip: AgentWorkSkip, + emit: AgentWorkEmitFn, + ): Promise { + if (!req.emitNames.skip) { + // Programming error — caller declared a gate that can return skip + // but didn't declare a skip event name. Log and silently drop. + this.logger.warn( + `agent-work[${req.metadata.source}]: skip='${skip.reason}' but no emitNames.skip configured — suppressing`, + ) + return + } + const payload = req.buildSkipPayload?.(req, skip) ?? skip.payload + try { + await emit(req.emitNames.skip, payload) + } catch (emitErr) { + this.logger.error( + `agent-work[${req.metadata.source}]: emit skip event failed:`, + emitErr, + ) + } + } +} From 014b8442c1ac091debba066b58cc3405e25437a8 Mon Sep 17 00:00:00 2001 From: Ame Date: Sun, 10 May 2026 19:35:05 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat(tool):=20add=20notify=5Fuser=20?= =?UTF-8?q?=E2=80=94=20intent=20signal=20for=20autonomous=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the heartbeat STATUS regex protocol with a structured tool call. AI-decides-to-notify becomes "AI calls notify_user(text)"; runner-side outputGate inspects the captured tool calls and routes through dedup / connectorCenter.notify. The tool's `execute` is intentionally a no-op (returns the args back as acknowledgement). Why no side-effects: heartbeat applies dedup before push; if the tool itself called connectorCenter.notify, we'd have no way to gate on dedup without per-tool source state. The runner-side gate is the right control point. The tool just records intent + arguments. Globally registered in ToolCenter — every session sees it. But only sessions whose persona prompt teaches AI when to call it (today only: heartbeat) actually exercise it. cron / task-router / chat keep their existing "every reply pushes" behaviour because their prompts don't reference notify_user. Followup commit teaches heartbeat's persona about it and wires the runner-side gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tool/notify-user.ts | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/tool/notify-user.ts diff --git a/src/tool/notify-user.ts b/src/tool/notify-user.ts new file mode 100644 index 00000000..d3c2fbd2 --- /dev/null +++ b/src/tool/notify-user.ts @@ -0,0 +1,63 @@ +import { tool } from 'ai' +import { z } from 'zod' + +/** + * notify_user — Alice's structured way to express "deliver this to + * the user" intent during autonomous work. + * + * Used by the heartbeat trigger source to replace the legacy STATUS + * regex protocol (`STATUS: HEARTBEAT_OK | CHAT_YES + CONTENT: ...`). + * The runner-side outputGate inspects the captured tool calls in + * `ProviderResult.toolCalls`; if `notify_user` was invoked, the gate + * routes the tool's `text` arg through the configured dedup window + * and into `connectorCenter.notify`. + * + * **Why no side-effects in execute**: the actual delivery is gated + * by the AgentWork outputGate (heartbeat applies dedup, future + * triggers might apply other policies). Putting `connectorCenter.notify` + * inside the tool's execute would make those gates impossible without + * per-tool-instance source state. The runner-side gate is the right + * control point. The tool's job is purely to signal intent + arguments. + * + * Globally registered by ToolCenter — every session sees it in its + * tool catalog. But only sessions whose persona prompt teaches Alice + * when to call it (today: heartbeat) actually exercise it. cron and + * task-router sessions don't reference it in their preambles, so AI + * keeps its current "every reply pushes" behaviour. + */ +export function createNotifyUserTool() { + return { + notify_user: tool({ + description: [ + 'Send a notification to the user. Use this during autonomous', + 'work (heartbeat / cron / external task) when something is', + 'worth surfacing — a market event, a finished analysis,', + 'a heads-up. Write the body in the user\'s language. Do not', + 'call this redundantly — one call per cycle is the norm. If', + 'nothing is worth flagging, simply do not call this tool.', + ].join(' '), + inputSchema: z.object({ + text: z + .string() + .min(1) + .describe( + 'The notification body, in the user\'s language. Keep it concise — under ~300 chars where possible.', + ), + urgency: z + .enum(['info', 'important']) + .optional() + .describe( + '"info" (default) for routine surfacing; "important" for time-sensitive matters the user should see promptly.', + ), + }), + execute: async ({ text, urgency }) => { + // Intent-only signal — the AgentWork runner's outputGate + // observes this call via ProviderResult.toolCalls and routes + // through dedup / connectorCenter.notify. Returning success + // here doesn't mean the user has been pinged yet; it means + // Alice's intent has been recorded for the runner to act on. + return { acknowledged: true, text, urgency: urgency ?? 'info' } + }, + }), + } +} From b857d7a539512c1e4e3ab1614dc72a10259d0db5 Mon Sep 17 00:00:00 2001 From: Ame Date: Sun, 10 May 2026 19:35:37 +0800 Subject: [PATCH 03/12] refactor(task): migrate heartbeat / cron / task-router to AgentWorkRunner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three trigger sources collapse into thin configurations of the AgentWork primitive. The shared body (subscribe → AI call → notify → emit done/error) lives in AgentWorkRunner; each listener is now just "how to translate a trigger event into an AgentWorkRequest". heartbeat (src/task/heartbeat/heartbeat.ts): - delete parseHeartbeatResponse() and the entire STATUS regex protocol — Alice now signals notification intent via the notify_user tool, not by emitting magic string tokens - default persona prompt rewritten to teach notify_user instead of STATUS / REASON / CONTENT format - active-hours guard becomes the runner's inputGate - notify_user inspection + dedup checks become the runner's outputGate; dedup record happens via onDelivered - HeartbeatDedup, isWithinActiveHours, the `__heartbeat__` cron job lifecycle, hot enable/disable — all kept (heartbeat-specific) - HeartbeatDedup.lastText is now public (load-bearing for the done event's `reply` field) - 410 → ~290 lines cron (src/task/cron/listener.ts): - 135 → ~110 lines - public API (createCronListener, CronListener, CronListenerOpts) preserved; just takes agentWorkRunner instead of agentCenter + connectorCenter - serial-execution lock + internal-job filter still here, since those are cron-specific (factory's pre-AI hook is the inputGate on a per-request basis; cron's `processing` lock is a listener- instance concern that pre-dates the request) task-router (src/task/task-router/listener.ts): - 122 → ~100 lines - same migration as cron - public API preserved main.ts: - constructs AgentWorkRunner once, threads to all three listeners - registers notify_user tool in toolCenter (globally available) heartbeat.spec.ts: rewritten — STATUS-regex tests deleted, replaced with notify_user-tool-call equivalents. New tests: - delivers when AI invokes notify_user (replaces "should call AI and write heartbeat.done") - skips with reason=ack when AI does not call notify_user (replaces "should skip HEARTBEAT_OK") - skips with reason=empty when notify_user.text is blank - explicit guard: STATUS-shaped raw text without notify_user is NOT delivered (anti-regression) - dedup: different texts not deduped - active-hours: outside window does not invoke AI - lifecycle / setEnabled / error handling preserved Test count: 35 → 28 (the parseHeartbeatResponse standalone test block, ~10 tests, deleted alongside the function it tested) cron + task-router specs: minimal setup change to construct via AgentWorkRunner; assertions unchanged. Full suite: 1622/1622 passing (was 1592 before). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 13 +- src/task/cron/listener.spec.ts | 8 +- src/task/cron/listener.ts | 88 +++--- src/task/heartbeat/heartbeat.spec.ts | 422 +++++++++++++------------- src/task/heartbeat/heartbeat.ts | 325 +++++++++----------- src/task/heartbeat/index.ts | 4 +- src/task/task-router/listener.spec.ts | 11 +- src/task/task-router/listener.ts | 74 +++-- 8 files changed, 442 insertions(+), 503 deletions(-) diff --git a/src/main.ts b/src/main.ts index 6e000456..e8201791 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,6 +35,8 @@ import { ConnectorCenter } from './core/connector-center.js' import { createNotificationsStore } from './core/notifications-store.js' import { ToolCenter } from './core/tool-center.js' import { AgentCenter } from './core/agent-center.js' +import { AgentWorkRunner } from './core/agent-work.js' +import { createNotifyUserTool } from './tool/notify-user.js' import { GenerateRouter } from './core/ai-provider-manager.js' import { VercelAIProvider } from './ai-providers/vercel-ai-sdk/vercel-provider.js' import { AgentSdkProvider } from './ai-providers/agent-sdk/agent-sdk-provider.js' @@ -244,6 +246,7 @@ async function main() { } toolCenter.register(createAnalysisTools(equityClient, cryptoClient, currencyClient, commodityClient), 'analysis') toolCenter.register(createEconomyTools(economyClient, commodityClient), 'economy') + toolCenter.register(createNotifyUserTool(), 'notify') console.log(`tool-center: ${toolCenter.list().length} tools registered`) @@ -278,11 +281,15 @@ async function main() { // Session awareness tools (registered here because they need connectorCenter) toolCenter.register(createSessionTools(connectorCenter), 'session') + // ==================== AgentWork runner — shared by all autonomous trigger sources ==================== + + const agentWorkRunner = new AgentWorkRunner({ agentCenter, connectorCenter }) + // ==================== Cron Listener ==================== const cronSession = new SessionStore('cron/default') await cronSession.restore() - const cronListener = createCronListener({ connectorCenter, agentCenter, registry: listenerRegistry, session: cronSession }) + const cronListener = createCronListener({ agentWorkRunner, registry: listenerRegistry, session: cronSession }) await cronListener.start() // ==================== Snapshot Scheduler ==================== @@ -297,7 +304,7 @@ async function main() { const heartbeat = createHeartbeat({ config: config.heartbeat, - connectorCenter, cronEngine, agentCenter, registry: listenerRegistry, + agentWorkRunner, cronEngine, registry: listenerRegistry, }) await heartbeat.start() if (config.heartbeat.enabled) { @@ -306,7 +313,7 @@ async function main() { // ==================== Task Router (external `task.requested` handler) ==================== - const taskRouter = createTaskRouter({ connectorCenter, agentCenter, registry: listenerRegistry }) + const taskRouter = createTaskRouter({ agentWorkRunner, registry: listenerRegistry }) await taskRouter.start() // ==================== Event Metrics (wildcard observer) ==================== diff --git a/src/task/cron/listener.spec.ts b/src/task/cron/listener.spec.ts index 35d4af9c..a7f7c09f 100644 --- a/src/task/cron/listener.spec.ts +++ b/src/task/cron/listener.spec.ts @@ -9,6 +9,7 @@ import { SessionStore } from '../../core/session.js' import type { CronFirePayload } from './engine.js' import { ConnectorCenter } from '../../core/connector-center.js' import { createMemoryNotificationsStore } from '../../core/notifications-store.js' +import { AgentWorkRunner } from '../../core/agent-work.js' function tempPath(ext: string): string { return join(tmpdir(), `cron-listener-test-${randomUUID()}.${ext}`) @@ -54,9 +55,12 @@ describe('cron listener', () => { notificationsStore = createMemoryNotificationsStore() connectorCenter = new ConnectorCenter({ notificationsStore }) - cronListener = createCronListener({ - connectorCenter, + const agentWorkRunner = new AgentWorkRunner({ agentCenter: mockEngine as any, + connectorCenter, + }) + cronListener = createCronListener({ + agentWorkRunner, registry, session, }) diff --git a/src/task/cron/listener.ts b/src/task/cron/listener.ts index e4366aae..bfc0e95e 100644 --- a/src/task/cron/listener.ts +++ b/src/task/cron/listener.ts @@ -1,21 +1,25 @@ /** - * Cron Listener — subscribes to `cron.fire` events from the EventLog - * and routes them through the AgentCenter for processing. + * Cron Listener — subscribes to `cron.fire` events and submits each as + * an AgentWork to the runner. The runner owns the AI call → notify → + * emit pipeline; this listener is a thin trigger source that: * - * Flow: - * eventLog 'cron.fire' → agentCenter.askWithSession(payload, session) - * → connectorCenter.notify(reply) - * → ctx.emit 'cron.done' / 'cron.error' + * 1. Filters out internal `__*__` jobs (heartbeat / snapshot have + * their own handlers) + * 2. Enforces serial execution (no overlapping cron handlings) + * 3. Builds an AgentWorkRequest with cron-shaped emit names + done + * payload + * 4. Delegates to `runner.run` * - * The listener owns a dedicated SessionStore for cron conversations, - * independent of user chat sessions (Telegram, Web, etc.). + * No notification policy lives here — every successful cron reply is + * pushed (the AgentWork default). If a future cron job wants + * AI-decides-to-notify semantics, its prompt can teach Alice about + * `notify_user` and supply an outputGate; the listener stays unchanged. */ import type { EventLogEntry } from '../../core/event-log.js' import type { CronFirePayload } from '../../core/agent-event.js' -import type { AgentCenter } from '../../core/agent-center.js' +import type { AgentWorkRunner } from '../../core/agent-work.js' import { SessionStore } from '../../core/session.js' -import type { ConnectorCenter } from '../../core/connector-center.js' import type { Listener, ListenerContext } from '../../core/listener.js' import type { ListenerRegistry } from '../../core/listener-registry.js' @@ -30,8 +34,7 @@ const CRON_EMITS = ['cron.done', 'cron.error'] as const type CronEmits = typeof CRON_EMITS export interface CronListenerOpts { - connectorCenter: ConnectorCenter - agentCenter: AgentCenter + agentWorkRunner: AgentWorkRunner /** Registry to auto-register this listener with. */ registry: ListenerRegistry /** Optional: inject a session for testing. Otherwise creates a dedicated cron session. */ @@ -50,7 +53,7 @@ export interface CronListener { // ==================== Factory ==================== export function createCronListener(opts: CronListenerOpts): CronListener { - const { connectorCenter, agentCenter, registry } = opts + const { agentWorkRunner, registry } = opts const session = opts.session ?? new SessionStore('cron/default') let processing = false @@ -66,50 +69,39 @@ export function createCronListener(opts: CronListenerOpts): CronListener { ): Promise { const payload = entry.payload - // Guard: internal jobs (__heartbeat__, __snapshot__, etc.) have dedicated handlers + // Internal jobs (__heartbeat__, __snapshot__, etc.) have dedicated handlers if (isInternalJob(payload.jobName)) return - // Guard: skip if already processing (serial execution) + // Serial execution — preserves today's behaviour if (processing) { console.warn(`cron-listener: skipping job ${payload.jobId} (already processing)`) return } processing = true - const startMs = Date.now() - try { - // Ask the AI engine with the cron payload - const result = await agentCenter.askWithSession(payload.payload, session, { - historyPreamble: `You are operating in the cron job context (session: cron/default, job: ${payload.jobName}). This is an automated cron job execution.`, - }) - - // Append to notifications store; connectors fan out via onAppended. - try { - await connectorCenter.notify(result.text, { - media: result.media, - source: 'cron', - }) - } catch (sendErr) { - console.warn(`cron-listener: notify failed for job ${payload.jobId}:`, sendErr) - } - - // Log success - await ctx.emit('cron.done', { - jobId: payload.jobId, - jobName: payload.jobName, - reply: result.text, - durationMs: Date.now() - startMs, - }) - } catch (err) { - console.error(`cron-listener: error processing job ${payload.jobId}:`, err) - - await ctx.emit('cron.error', { - jobId: payload.jobId, - jobName: payload.jobName, - error: err instanceof Error ? err.message : String(err), - durationMs: Date.now() - startMs, - }) + await agentWorkRunner.run( + { + prompt: payload.payload, + session, + preamble: `You are operating in the cron job context (session: cron/default, job: ${payload.jobName}). This is an automated cron job execution.`, + metadata: { source: 'cron', jobId: payload.jobId, jobName: payload.jobName }, + emitNames: { done: 'cron.done', error: 'cron.error' }, + buildDonePayload: (req, result, durationMs) => ({ + jobId: req.metadata.jobId as string, + jobName: req.metadata.jobName as string, + reply: result.text, + durationMs, + }), + buildErrorPayload: (req, err, durationMs) => ({ + jobId: req.metadata.jobId as string, + jobName: req.metadata.jobName as string, + error: err.message, + durationMs, + }), + }, + ctx.emit as never, + ) } finally { processing = false } diff --git a/src/task/heartbeat/heartbeat.spec.ts b/src/task/heartbeat/heartbeat.spec.ts index d44e7652..e69b7763 100644 --- a/src/task/heartbeat/heartbeat.spec.ts +++ b/src/task/heartbeat/heartbeat.spec.ts @@ -1,3 +1,26 @@ +/** + * Heartbeat tests — exercises the full trigger-source pipeline: + * + * cron.fire (__heartbeat__) + * → handleFire() + * → AgentWorkRunner.run() + * → inputGate (active-hours) + * → AI invocation + * → outputGate (notify_user inspection + dedup) + * → connectorCenter.notify (optional) + * → emit done / skip / error + * + * The legacy STATUS regex protocol is gone. Heartbeat now signals + * notification intent via the `notify_user` tool — these tests mock + * the AgentCenter result to include or omit the tool call, and assert + * on the resulting events. + * + * AgentWork primitive coverage lives in `src/core/agent-work.spec.ts`; + * this file tests heartbeat-specific behaviours: cron job lifecycle, + * active-hours filtering, dedup window, hot enable/disable, and the + * heartbeat-specific outputGate semantics. + */ + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -7,7 +30,6 @@ import { createListenerRegistry, type ListenerRegistry } from '../../core/listen import { createCronEngine, type CronEngine } from '../cron/engine.js' import { createHeartbeat, - parseHeartbeatResponse, isWithinActiveHours, HeartbeatDedup, HEARTBEAT_JOB_NAME, @@ -17,6 +39,8 @@ import { import { SessionStore } from '../../core/session.js' import { ConnectorCenter } from '../../core/connector-center.js' import { createMemoryNotificationsStore } from '../../core/notifications-store.js' +import { AgentWorkRunner } from '../../core/agent-work.js' +import type { ToolCallSummary } from '../../ai-providers/types.js' // Mock writeConfigSection to avoid disk writes in tests vi.mock('../../core/config.js', () => ({ @@ -38,22 +62,49 @@ function makeConfig(overrides: Partial = {}): HeartbeatConfig { } // ==================== Mock Engine ==================== +// +// Returns `{ text, media, toolCalls }` from `askWithSession`. The +// runner unwraps these as ProviderResult; toolCalls is what the +// heartbeat outputGate inspects for notify_user invocations. + +interface MockEngineState { + text: string + toolCalls: ToolCallSummary[] + shouldThrow: Error | null +} -const CHAT_YES_RESPONSE = `STATUS: CHAT_YES -REASON: Significant price movement detected. -CONTENT: Market alert: BTC dropped 5%` +function createMockEngine(initial: Partial = {}) { + const state: MockEngineState = { + text: '', + toolCalls: [], + shouldThrow: null, + ...initial, + } -function createMockEngine(response = CHAT_YES_RESPONSE) { return { - _response: response, - setResponse(text: string) { this._response = text }, - askWithSession: vi.fn(async function (this: any) { - return { text: this._response, media: [] } + state, + setNotifyUserCall(text: string) { + state.toolCalls = [{ id: randomUUID(), name: 'notify_user', input: { text } }] + }, + setNoToolCall() { + state.toolCalls = [] + }, + setRawText(text: string) { + state.text = text + }, + setShouldThrow(err: Error | null) { + state.shouldThrow = err + }, + askWithSession: vi.fn(async () => { + if (state.shouldThrow) throw state.shouldThrow + return { text: state.text, media: [], toolCalls: state.toolCalls } }), ask: vi.fn(), } } +// ==================== Integration suite ==================== + describe('heartbeat', () => { let eventLog: EventLog let listenerRegistry: ListenerRegistry @@ -63,13 +114,14 @@ describe('heartbeat', () => { let session: SessionStore let connectorCenter: ConnectorCenter let notificationsStore: ReturnType + let agentWorkRunner: AgentWorkRunner beforeEach(async () => { const logPath = tempPath('jsonl') const storePath = tempPath('json') eventLog = await createEventLog({ logPath }) listenerRegistry = createListenerRegistry(eventLog) - await listenerRegistry.start() // Start registry so late registrations subscribe immediately + await listenerRegistry.start() cronEngine = createCronEngine({ registry: listenerRegistry, storePath }) await cronEngine.start() @@ -77,6 +129,10 @@ describe('heartbeat', () => { session = new SessionStore(`test/heartbeat-${randomUUID()}`) notificationsStore = createMemoryNotificationsStore() connectorCenter = new ConnectorCenter({ notificationsStore }) + agentWorkRunner = new AgentWorkRunner({ + agentCenter: mockEngine as never, + connectorCenter, + }) }) afterEach(async () => { @@ -92,9 +148,7 @@ describe('heartbeat', () => { it('should register a cron job on start', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() @@ -108,37 +162,27 @@ describe('heartbeat', () => { it('should be idempotent (update existing job, not create duplicate)', async () => { heartbeat = createHeartbeat({ config: makeConfig({ every: '30m' }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) - await heartbeat.start() heartbeat.stop() - // Start again with different interval heartbeat = createHeartbeat({ config: makeConfig({ every: '1h' }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) - await heartbeat.start() const jobs = cronEngine.list() - expect(jobs).toHaveLength(1) // not 2 + expect(jobs).toHaveLength(1) expect(jobs[0].schedule).toEqual({ kind: 'every', every: '1h' }) }) it('should register disabled job when config.enabled is false', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) - await heartbeat.start() const jobs = cronEngine.list() @@ -148,106 +192,109 @@ describe('heartbeat', () => { }) }) - // ==================== Event Handling ==================== + // ==================== Event Handling: notify_user contract ==================== describe('event handling', () => { - it('should call AI and write heartbeat.done on real response', async () => { + it('delivers when AI invokes notify_user', async () => { const delivered: string[] = [] notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + mockEngine.setNotifyUserCall('BTC dropped 5% to $87,200') + heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - // Simulate cron.fire for heartbeat await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(1) }) - expect(delivered).toHaveLength(1) - expect(delivered[0]).toBe('Market alert: BTC dropped 5%') - + expect(delivered).toEqual(['BTC dropped 5% to $87,200']) const done = eventLog.recent({ type: 'heartbeat.done' }) expect(done[0].payload).toMatchObject({ - reply: 'Market alert: BTC dropped 5%', + reply: 'BTC dropped 5% to $87,200', delivered: true, }) }) - it('should skip HEARTBEAT_OK responses', async () => { - mockEngine.setResponse('STATUS: HEARTBEAT_OK\nREASON: All systems normal.') + it('skips with reason=ack when AI does not call notify_user', async () => { + mockEngine.setRawText('Checked. Nothing notable in the last 30 minutes.') + mockEngine.setNoToolCall() heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.skip' })).toHaveLength(1) }) const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips[0].payload).toMatchObject({ reason: 'ack', parsedReason: 'All systems normal.' }) - - // Should NOT have heartbeat.done + expect(skips[0].payload).toMatchObject({ reason: 'ack' }) + // No notify, no done expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(0) }) - it('should deliver unparsed responses (fail-open)', async () => { - const delivered: string[] = [] - notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + it('skips with reason=empty when notify_user.text is blank', async () => { + mockEngine.setNotifyUserCall(' ') + + heartbeat = createHeartbeat({ + config: makeConfig(), + agentWorkRunner, cronEngine, registry: listenerRegistry, session, + }) + await heartbeat.start() + + await cronEngine.runNow(cronEngine.list()[0].id) - // Raw text without structured format - mockEngine.setResponse('BTC just crashed 15%, major liquidation event!') + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'heartbeat.skip' })).toHaveLength(1) + }) + + expect((eventLog.recent({ type: 'heartbeat.skip' })[0].payload as { reason: string }).reason).toBe('empty') + }) + + it('does NOT regex-parse the AI response — STATUS-shaped text without notify_user is still skipped', async () => { + // Old protocol response — must NOT trigger any notification under + // the new contract. The AI must call the tool to deliver. + mockEngine.setRawText('STATUS: CHAT_YES\nREASON: x\nCONTENT: this should NOT be delivered') + mockEngine.setNoToolCall() heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.skip' })).toHaveLength(1) }) - expect(delivered).toHaveLength(1) - expect(delivered[0]).toBe('BTC just crashed 15%, major liquidation event!') + const { entries } = await notificationsStore.read() + expect(entries).toHaveLength(0) }) - it('should ignore non-heartbeat cron.fire events', async () => { + it('ignores non-heartbeat cron.fire events', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - // Fire a non-heartbeat cron event await eventLog.append('cron.fire', { jobId: 'other-job', jobName: 'check-eth', payload: 'Check ETH price', }) - await new Promise((r) => setTimeout(r, 50)) expect(mockEngine.askWithSession).not.toHaveBeenCalled() @@ -257,17 +304,14 @@ describe('heartbeat', () => { // ==================== Active Hours ==================== describe('active hours', () => { - it('should skip when outside active hours', async () => { - // Set active hours to a window that excludes the test time + it('skips when outside active hours, without invoking AI', async () => { const fakeNow = new Date('2025-06-15T03:00:00').getTime() // 3 AM local heartbeat = createHeartbeat({ config: makeConfig({ activeHours: { start: '09:00', end: '22:00', timezone: 'local' }, }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, now: () => fakeNow, }) await heartbeat.start() @@ -275,12 +319,11 @@ describe('heartbeat', () => { await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.skip' })).toHaveLength(1) }) const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips[0].payload).toMatchObject({ reason: 'outside-active-hours' }) + expect((skips[0].payload as { reason: string }).reason).toBe('outside-active-hours') expect(mockEngine.askWithSession).not.toHaveBeenCalled() }) }) @@ -288,88 +331,106 @@ describe('heartbeat', () => { // ==================== Dedup ==================== describe('dedup', () => { - it('should suppress duplicate messages within 24h', async () => { + it('suppresses duplicate notify_user texts within the dedup window', async () => { const delivered: string[] = [] notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + mockEngine.setNotifyUserCall('BTC dropped 5%') + heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() const jobId = cronEngine.list()[0].id - // First fire — should deliver. Wait for heartbeat.done to ensure - // the full handleFire cycle (including dedup.record) has completed - // before triggering the second fire. + // First fire — delivered await cronEngine.runNow(jobId) await vi.waitFor(() => { expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(1) }) - // Second fire (same response) — should be suppressed + // Second fire (same notify_user text) — should be deduped await cronEngine.runNow(jobId) await vi.waitFor(() => { const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips.some((s) => (s.payload as any).reason === 'duplicate')).toBe(true) + expect(skips.some((s) => (s.payload as { reason: string }).reason === 'duplicate')).toBe(true) }) - expect(delivered).toHaveLength(1) // still 1, not 2 + expect(delivered).toHaveLength(1) + }) + + it('different notify_user texts are not deduped', async () => { + const delivered: string[] = [] + notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + + heartbeat = createHeartbeat({ + config: makeConfig(), + agentWorkRunner, cronEngine, registry: listenerRegistry, session, + }) + await heartbeat.start() + const jobId = cronEngine.list()[0].id + + mockEngine.setNotifyUserCall('First alert') + await cronEngine.runNow(jobId) + await vi.waitFor(() => { + expect(delivered).toHaveLength(1) + }) + + mockEngine.setNotifyUserCall('Second different alert') + await cronEngine.runNow(jobId) + await vi.waitFor(() => { + expect(delivered).toHaveLength(2) + }) + + expect(delivered).toEqual(['First alert', 'Second different alert']) }) }) // ==================== Error Handling ==================== describe('error handling', () => { - it('should write heartbeat.error on engine failure', async () => { - mockEngine.askWithSession.mockRejectedValueOnce(new Error('AI down')) + it('emits heartbeat.error on AI failure', async () => { + mockEngine.setShouldThrow(new Error('AI down')) heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const errors = eventLog.recent({ type: 'heartbeat.error' }) - expect(errors).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.error' })).toHaveLength(1) }) const errors = eventLog.recent({ type: 'heartbeat.error' }) expect(errors[0].payload).toMatchObject({ error: 'AI down' }) }) - it('should handle notify failure gracefully', async () => { - // Force the underlying append to reject so the heartbeat sees a - // failed notify path. Heartbeat must still emit heartbeat.done - // (with delivered=false) and not crash. + it('handles notify failure gracefully — emits done with delivered=false', async () => { + mockEngine.setNotifyUserCall('alert text') + // Force the underlying append to reject. The runner should still + // emit done with delivered=false; the listener should not crash. const originalAppend = notificationsStore.append.bind(notificationsStore) notificationsStore.append = async () => { throw new Error('store failed') } heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(1) }) const done = eventLog.recent({ type: 'heartbeat.done' }) - expect((done[0].payload as any).delivered).toBe(false) + expect((done[0].payload as { delivered: boolean }).delivered).toBe(false) notificationsStore.append = originalAppend }) @@ -378,12 +439,10 @@ describe('heartbeat', () => { // ==================== Lifecycle ==================== describe('lifecycle', () => { - it('should stop listening after stop()', async () => { + it('stops listening after stop()', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() heartbeat.stop() @@ -398,12 +457,10 @@ describe('heartbeat', () => { // ==================== setEnabled / isEnabled ==================== describe('setEnabled', () => { - it('should enable a previously disabled heartbeat', async () => { + it('enables a previously disabled heartbeat', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() @@ -416,12 +473,10 @@ describe('heartbeat', () => { expect(cronEngine.list()[0].enabled).toBe(true) }) - it('should disable an enabled heartbeat', async () => { + it('disables an enabled heartbeat', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: true }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() @@ -433,128 +488,53 @@ describe('heartbeat', () => { expect(cronEngine.list()[0].enabled).toBe(false) }) - it('should persist config via writeConfigSection', async () => { + it('persists config via writeConfigSection', async () => { const { writeConfigSection } = await import('../../core/config.js') heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await heartbeat.setEnabled(true) - expect(writeConfigSection).toHaveBeenCalledWith('heartbeat', expect.objectContaining({ enabled: true })) + expect(writeConfigSection).toHaveBeenCalledWith( + 'heartbeat', + expect.objectContaining({ enabled: true }), + ) }) - it('should allow firing after setEnabled(true)', async () => { + it('allows firing after setEnabled(true)', async () => { const delivered: string[] = [] notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + mockEngine.setNotifyUserCall('after-enable') + heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - - // Enable heartbeat await heartbeat.setEnabled(true) - // Fire — should process since now enabled await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { expect(delivered).toHaveLength(1) }) + expect(delivered[0]).toBe('after-enable') }) }) }) -// ==================== Unit Tests: parseHeartbeatResponse ==================== - -describe('parseHeartbeatResponse', () => { - it('should parse HEARTBEAT_OK', () => { - const r = parseHeartbeatResponse('STATUS: HEARTBEAT_OK\nREASON: All good.') - expect(r.status).toBe('HEARTBEAT_OK') - expect(r.reason).toBe('All good.') - expect(r.content).toBe('') - expect(r.unparsed).toBe(false) - }) - - it('should treat former CHAT_NO as unparsed (fail-open to CHAT_YES)', () => { - const r = parseHeartbeatResponse('STATUS: CHAT_NO\nREASON: Nothing worth reporting.') - expect(r.status).toBe('CHAT_YES') - expect(r.unparsed).toBe(true) - }) - - it('should parse CHAT_YES with content', () => { - const r = parseHeartbeatResponse( - 'STATUS: CHAT_YES\nREASON: Price alert.\nCONTENT: BTC dropped 8% to $87,200.', - ) - expect(r.status).toBe('CHAT_YES') - expect(r.reason).toBe('Price alert.') - expect(r.content).toBe('BTC dropped 8% to $87,200.') - expect(r.unparsed).toBe(false) - }) - - it('should parse CHAT_YES with multiline content', () => { - const r = parseHeartbeatResponse( - 'STATUS: CHAT_YES\nREASON: Multiple alerts.\nCONTENT: Line 1\nLine 2\nLine 3', - ) - expect(r.status).toBe('CHAT_YES') - expect(r.content).toBe('Line 1\nLine 2\nLine 3') - }) - - it('should be case-insensitive for STATUS field', () => { - const r = parseHeartbeatResponse('status: heartbeat_ok\nreason: ok') - expect(r.status).toBe('HEARTBEAT_OK') - }) - - it('should handle extra whitespace', () => { - const r = parseHeartbeatResponse(' STATUS: HEARTBEAT_OK \n REASON: All quiet. ') - expect(r.status).toBe('HEARTBEAT_OK') - expect(r.reason).toBe('All quiet.') - }) - - it('should fail-open on unparseable response (deliver it)', () => { - const r = parseHeartbeatResponse('Something unexpected happened with BTC!') - expect(r.status).toBe('CHAT_YES') - expect(r.content).toBe('Something unexpected happened with BTC!') - expect(r.unparsed).toBe(true) - }) - - it('should handle empty input', () => { - const r = parseHeartbeatResponse('') - expect(r.status).toBe('HEARTBEAT_OK') - expect(r.content).toBe('') - expect(r.unparsed).toBe(false) - }) - - it('should handle response with only STATUS line', () => { - const r = parseHeartbeatResponse('STATUS: HEARTBEAT_OK') - expect(r.status).toBe('HEARTBEAT_OK') - expect(r.reason).toBe('') - }) - - it('should handle CHAT_YES without CONTENT field', () => { - const r = parseHeartbeatResponse('STATUS: CHAT_YES\nREASON: Want to say hi.') - expect(r.status).toBe('CHAT_YES') - expect(r.content).toBe('') - }) -}) - -// ==================== Unit Tests: isWithinActiveHours ==================== +// ==================== Unit: isWithinActiveHours ==================== describe('isWithinActiveHours', () => { - it('should return true when no active hours configured', () => { + it('returns true when no active hours configured', () => { expect(isWithinActiveHours(null)).toBe(true) }) - it('should return true within normal range', () => { - // 15:00 local → within 09:00-22:00 + it('returns true within normal range', () => { const ts = todayAt(15, 0).getTime() expect(isWithinActiveHours( { start: '09:00', end: '22:00', timezone: 'local' }, @@ -562,8 +542,7 @@ describe('isWithinActiveHours', () => { )).toBe(true) }) - it('should return false outside normal range', () => { - // 03:00 local → outside 09:00-22:00 + it('returns false outside normal range', () => { const ts = todayAt(3, 0).getTime() expect(isWithinActiveHours( { start: '09:00', end: '22:00', timezone: 'local' }, @@ -571,63 +550,68 @@ describe('isWithinActiveHours', () => { )).toBe(false) }) - it('should handle overnight range (22:00 → 06:00)', () => { - const ts = todayAt(23, 0).getTime() + it('handles overnight range (22:00 → 06:00)', () => { expect(isWithinActiveHours( { start: '22:00', end: '06:00', timezone: 'local' }, - ts, + todayAt(23, 0).getTime(), )).toBe(true) - const ts2 = todayAt(3, 0).getTime() expect(isWithinActiveHours( { start: '22:00', end: '06:00', timezone: 'local' }, - ts2, + todayAt(3, 0).getTime(), )).toBe(true) - const ts3 = todayAt(12, 0).getTime() expect(isWithinActiveHours( { start: '22:00', end: '06:00', timezone: 'local' }, - ts3, + todayAt(12, 0).getTime(), )).toBe(false) }) - it('should handle invalid format gracefully (return true)', () => { + it('handles invalid format gracefully (returns true)', () => { expect(isWithinActiveHours( { start: 'invalid', end: '22:00', timezone: 'local' }, )).toBe(true) }) }) -// ==================== Unit Tests: HeartbeatDedup ==================== +// ==================== Unit: HeartbeatDedup ==================== describe('HeartbeatDedup', () => { - it('should not flag first message as duplicate', () => { + it('does not flag first message as duplicate', () => { const d = new HeartbeatDedup() expect(d.isDuplicate('hello')).toBe(false) }) - it('should flag same text within window', () => { + it('flags same text within window', () => { const d = new HeartbeatDedup(1000) d.record('hello', 100) expect(d.isDuplicate('hello', 500)).toBe(true) }) - it('should not flag same text after window expires', () => { + it('does not flag same text after window expires', () => { const d = new HeartbeatDedup(1000) d.record('hello', 100) expect(d.isDuplicate('hello', 1200)).toBe(false) }) - it('should not flag different text', () => { + it('does not flag different text', () => { const d = new HeartbeatDedup(1000) d.record('hello', 100) expect(d.isDuplicate('world', 500)).toBe(false) }) + + it('exposes lastText (load-bearing for buildDonePayload)', () => { + const d = new HeartbeatDedup() + expect(d.lastText).toBeNull() + d.record('first', 100) + expect(d.lastText).toBe('first') + d.record('second', 200) + expect(d.lastText).toBe('second') + }) }) // ==================== Helpers ==================== -/** Create a Date set to today at the given local hour and minute. */ function todayAt(h: number, m: number): Date { const d = new Date() d.setHours(h, m, 0, 0) diff --git a/src/task/heartbeat/heartbeat.ts b/src/task/heartbeat/heartbeat.ts index e924f600..9978d3eb 100644 --- a/src/task/heartbeat/heartbeat.ts +++ b/src/task/heartbeat/heartbeat.ts @@ -1,29 +1,40 @@ /** * Heartbeat — periodic AI self-check, built on top of the cron engine. * - * Registers a cron job (`__heartbeat__`) that fires at a configured interval. - * When fired, calls the AI engine and filters the response: - * 1. Active hours guard — skip if outside configured window - * 2. AI call — agentCenter.askWithSession(prompt, heartbeatSession) - * 3. Ack token filter — skip if AI says "nothing to report" - * 4. Dedup — skip if same text was sent within 24h - * 5. Send — connectorCenter.notify(text) + * Registers a cron job (`__heartbeat__`) that fires at a configured + * interval. Each fire is submitted to AgentWorkRunner with two gates: * - * Events written to eventLog: - * - heartbeat.done { reply, durationMs, delivered } - * - heartbeat.skip { reason } + * - **inputGate**: active-hours filter — skip without spending tokens + * when outside the configured window + * - **outputGate**: inspect AI's tool calls — if `notify_user` was + * invoked, deliver its `text` arg (after dedup); otherwise skip + * silently with reason='ack' + * - **onDelivered**: record dedup state on successful delivery + * + * Replaces the legacy STATUS regex protocol (`STATUS: HEARTBEAT_OK | + * CHAT_YES + CONTENT: ...`) with structured tool-call signalling. The + * runner-side gate handles dedup before the notification reaches + * connectors, which means duplicate suppression and active-hours + * filtering are uniform across configurations. + * + * Events emitted: + * - heartbeat.done { reply, reason, durationMs, delivered } + * - heartbeat.skip { reason, parsedReason? } * - heartbeat.error { error, durationMs } + * + * Heartbeat-specific state stays in this module: + * - `HeartbeatDedup` — in-memory 24h window + * - `__heartbeat__` cron job lifecycle (idempotent add/update, + * hot-toggle via setEnabled) + * - active-hours config + tz-aware time-of-day check */ -import type { EventLogEntry } from '../../core/event-log.js' -import type { CronFirePayload } from '../../core/agent-event.js' -import type { AgentCenter } from '../../core/agent-center.js' +import type { AgentWorkRunner, AgentWorkResultProbe } from '../../core/agent-work.js' +import type { Listener } from '../../core/listener.js' +import type { ListenerRegistry } from '../../core/listener-registry.js' import { SessionStore } from '../../core/session.js' -import type { ConnectorCenter } from '../../core/connector-center.js' import { writeConfigSection } from '../../core/config.js' import type { CronEngine } from '../cron/engine.js' -import type { Listener, ListenerContext } from '../../core/listener.js' -import type { ListenerRegistry } from '../../core/listener-registry.js' const HEARTBEAT_EMITS = ['heartbeat.done', 'heartbeat.skip', 'heartbeat.error'] as const type HeartbeatEmits = typeof HEARTBEAT_EMITS @@ -51,29 +62,15 @@ export interface HeartbeatConfig { export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = { enabled: false, every: '30m', - prompt: `Check if anything needs attention. Respond using the structured format below. - -## Response Format - -STATUS: HEARTBEAT_OK | CHAT_YES -REASON: -CONTENT: + prompt: `You're Alice in the heartbeat monitoring loop. The system pings you periodically so you can check on what's happening — markets, watchlists, pending items, anything trade-relevant the user might want surfaced. -## Rules +If something is genuinely worth flagging — a notable move, a finished analysis, an answer to a question they've been waiting on — call the \`notify_user\` tool with a concise message in the user's language. -- If in doubt, prefer CHAT_YES over HEARTBEAT_OK — better to over-report than to miss something. -- Keep CONTENT concise but actionable. +If there's nothing worth surfacing, simply respond briefly with what you observed (or with nothing at all). Don't call \`notify_user\` out of politeness; reserve it for genuinely useful pushes — the user gets pinged whenever it fires. -## Examples - -If nothing to report: -STATUS: HEARTBEAT_OK -REASON: All systems normal, no alerts or notable changes. - -If you want to send a message: -STATUS: CHAT_YES -REASON: Significant price movement detected. -CONTENT: BTC just dropped 8% in the last hour — now at $87,200. This may trigger stop-losses.`, +In short: +- silence = nothing pushed +- \`notify_user("...")\` = a push lands in the user's inbox`, activeHours: null, } @@ -81,9 +78,8 @@ CONTENT: BTC just dropped 8% in the last hour — now at $87,200. This may trigg export interface HeartbeatOpts { config: HeartbeatConfig - connectorCenter: ConnectorCenter + agentWorkRunner: AgentWorkRunner cronEngine: CronEngine - agentCenter: AgentCenter /** Registry to auto-register the heartbeat listener with. */ registry: ListenerRegistry /** Optional: inject a session for testing. */ @@ -106,7 +102,7 @@ export interface Heartbeat { // ==================== Factory ==================== export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { - const { config, connectorCenter, cronEngine, agentCenter, registry } = opts + const { config, agentWorkRunner, cronEngine, registry } = opts const session = opts.session ?? new SessionStore('heartbeat') const now = opts.now ?? Date.now @@ -117,111 +113,112 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { const dedup = new HeartbeatDedup() - async function handleFire( - entry: EventLogEntry, - ctx: ListenerContext, - ): Promise { - const payload = entry.payload - - // Only handle our own job - if (payload.jobName !== HEARTBEAT_JOB_NAME) return - - // Guard: skip if already processing - if (processing) return - - processing = true - const startMs = now() - console.log(`heartbeat: firing at ${new Date(startMs).toISOString()}`) - - try { - // 1. Active hours guard - if (!isWithinActiveHours(config.activeHours, now())) { - console.log('heartbeat: skipped (outside active hours)') - await ctx.emit('heartbeat.skip', { reason: 'outside-active-hours' }) - return - } + const listener: Listener<'cron.fire', HeartbeatEmits> = { + name: 'heartbeat', + subscribes: 'cron.fire', + emits: HEARTBEAT_EMITS, + async handle(entry, ctx) { + const payload = entry.payload - // 2. Call AI - const result = await agentCenter.askWithSession(payload.payload, session, { - historyPreamble: 'You are operating in the heartbeat monitoring context (session: heartbeat). The following is the recent heartbeat conversation history.', - }) - const durationMs = now() - startMs - - // 3. Parse structured response - const parsed = parseHeartbeatResponse(result.text) - - if (parsed.status === 'HEARTBEAT_OK') { - console.log(`heartbeat: HEARTBEAT_OK — ${parsed.reason || 'no reason'} (${durationMs}ms)`) - await ctx.emit('heartbeat.skip', { - reason: 'ack', - parsedReason: parsed.reason, - }) - return - } + // Filter to our own cron job + if (payload.jobName !== HEARTBEAT_JOB_NAME) return - // CHAT_YES (or unparsed fallback) - const text = parsed.content || result.text - if (!text.trim()) { - console.log(`heartbeat: skipped (empty content) (${durationMs}ms)`) - await ctx.emit('heartbeat.skip', { reason: 'empty' }) - return - } + // Serial — preserve today's behaviour. Concurrent heartbeats would + // be ambiguous wrt dedup state. + if (processing) return - // 4. Dedup - if (dedup.isDuplicate(text, now())) { - console.log(`heartbeat: skipped (duplicate) (${durationMs}ms)`) - await ctx.emit('heartbeat.skip', { reason: 'duplicate' }) - return - } - - // 5. Append to notifications store. Connectors (Web bell, Telegram - // inline, …) subscribe to the store's onAppended event and - // surface the notification however suits their transport. - let appended = false + processing = true + const startMs = now() + console.log(`heartbeat: firing at ${new Date(startMs).toISOString()}`) try { - await connectorCenter.notify(text, { - media: result.media, - source: 'heartbeat', - }) - appended = true - // Record dedup unconditionally on append: the notification exists - // in the canonical record, even if a particular connector chose - // not to surface it (e.g. Telegram skipping when user is inactive). - dedup.record(text, now()) - } catch (sendErr) { - console.warn('heartbeat: notify failed:', sendErr) + const result = await agentWorkRunner.run( + { + prompt: payload.payload, + session, + preamble: + 'You are operating in the heartbeat monitoring context (session: heartbeat). The following is the recent heartbeat conversation history.', + metadata: { source: 'heartbeat' }, + + // ---- inputGate: active-hours guard ---- + inputGate: () => + isWithinActiveHours(config.activeHours, now()) + ? null + : { + reason: 'outside-active-hours', + payload: { reason: 'outside-active-hours' }, + }, + + // ---- outputGate: notify_user inspection + dedup ---- + outputGate: (probe: AgentWorkResultProbe) => { + const call = probe.toolCalls.find((c) => c.name === 'notify_user') + if (!call) { + return { + kind: 'skip', + reason: 'ack', + payload: { reason: 'ack' }, + } + } + const text = ((call.input ?? {}) as { text?: string }).text ?? '' + if (!text.trim()) { + return { + kind: 'skip', + reason: 'empty', + payload: { reason: 'empty' }, + } + } + if (dedup.isDuplicate(text, now())) { + return { + kind: 'skip', + reason: 'duplicate', + payload: { reason: 'duplicate', parsedReason: text.slice(0, 80) }, + } + } + return { kind: 'deliver', text, media: probe.media } + }, + + // ---- onDelivered: record dedup state ---- + onDelivered: (text) => dedup.record(text, now()), + + emitNames: { + done: 'heartbeat.done', + skip: 'heartbeat.skip', + error: 'heartbeat.error', + }, + buildDonePayload: (_req, _result, durationMs, delivered) => { + // Look up what we actually delivered (the text the AI passed + // through notify_user). The runner already invoked notify with + // the gate's chosen text; for the done payload we re-derive it + // from the dedup state — `dedup.lastText` is what we just sent. + const reply = dedup.lastText ?? '' + return { + reply, + reason: 'notify_user', + durationMs, + delivered, + } + }, + buildErrorPayload: (_req, err, durationMs) => ({ + error: err.message, + durationMs, + }), + }, + ctx.emit as never, + ) + + const durationMs = now() - startMs + console.log( + `heartbeat: ${result.outcome}` + + (result.skipReason ? ` reason=${result.skipReason}` : '') + + ` (${durationMs}ms)`, + ) + } finally { + processing = false } - - console.log(`heartbeat: CHAT_YES — appended=${appended} (${durationMs}ms)`) - - // 6. Done event - await ctx.emit('heartbeat.done', { - reply: text, - reason: parsed.reason, - durationMs, - delivered: appended, - }) - } catch (err) { - console.error('heartbeat: error:', err) - await ctx.emit('heartbeat.error', { - error: err instanceof Error ? err.message : String(err), - durationMs: now() - startMs, - }) - } finally { - processing = false - } - } - - const listener: Listener<'cron.fire', HeartbeatEmits> = { - name: 'heartbeat', - subscribes: 'cron.fire', - emits: HEARTBEAT_EMITS, - handle: handleFire, + }, } /** Ensure the cron job exists and listener is registered (idempotent). */ async function ensureJobAndListener(): Promise { - // Idempotent: find existing heartbeat job or create one const existing = cronEngine.list().find((j) => j.name === HEARTBEAT_JOB_NAME) if (existing) { jobId = existing.id @@ -239,7 +236,6 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { }) } - // Register listener exactly once if (!registered) { registry.register(listener) registered = true @@ -278,55 +274,6 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { } } -// ==================== Response Parser ==================== - -export type HeartbeatStatus = 'HEARTBEAT_OK' | 'CHAT_YES' - -export interface ParsedHeartbeatResponse { - status: HeartbeatStatus - reason: string - content: string - /** True when the raw response couldn't be parsed into the structured format. */ - unparsed: boolean -} - -/** - * Parse a structured heartbeat response from the AI. - * - * Expected format: - * STATUS: HEARTBEAT_OK | CHAT_YES - * REASON: - * CONTENT: (only for CHAT_YES) - * - * If the response doesn't match the expected format, treats the entire - * raw text as a CHAT_YES message (fail-open: deliver rather than swallow). - */ -export function parseHeartbeatResponse(raw: string): ParsedHeartbeatResponse { - const trimmed = raw.trim() - if (!trimmed) { - return { status: 'HEARTBEAT_OK', reason: 'empty response', content: '', unparsed: false } - } - - // Extract STATUS field (case-insensitive, allows leading whitespace on the line) - const statusMatch = /^\s*STATUS:\s*(HEARTBEAT_OK|CHAT_YES)\s*$/im.exec(trimmed) - if (!statusMatch) { - // Fail-open: can't parse → treat as a message to deliver - return { status: 'CHAT_YES', reason: 'unparsed response', content: trimmed, unparsed: true } - } - - const status = statusMatch[1].toUpperCase() as HeartbeatStatus - - // Extract REASON field (everything after "REASON:" until next field or end) - const reasonMatch = /^\s*REASON:\s*(.+?)(?=\n\s*(?:STATUS|CONTENT):|\s*$)/ims.exec(trimmed) - const reason = reasonMatch?.[1]?.trim() ?? '' - - // Extract CONTENT field (everything after "CONTENT:" to end) - const contentMatch = /^\s*CONTENT:\s*(.+)/ims.exec(trimmed) - const content = contentMatch?.[1]?.trim() ?? '' - - return { status, reason, content, unparsed: false } -} - // ==================== Active Hours ==================== /** @@ -392,9 +339,15 @@ function currentMinutesInTimezone(tz: string, nowMs?: number): number { /** * Suppress identical heartbeat messages within a time window (default 24h). + * + * In-memory only — restart loses dedup state. Acceptable trade-off: + * heartbeat fires every ~30m by default, so a restart-window + * collision is rare and the cost (one duplicate notification) is low. */ export class HeartbeatDedup { - private lastText: string | null = null + /** Public for the heartbeat factory's `buildDonePayload` to read the + * most-recently-delivered text without an extra signal channel. */ + public lastText: string | null = null private lastSentAt = 0 private windowMs: number diff --git a/src/task/heartbeat/index.ts b/src/task/heartbeat/index.ts index ddad214f..9986bbe3 100644 --- a/src/task/heartbeat/index.ts +++ b/src/task/heartbeat/index.ts @@ -1,3 +1,3 @@ -export { createHeartbeat, HEARTBEAT_JOB_NAME } from './heartbeat.js' +export { createHeartbeat, HEARTBEAT_JOB_NAME, DEFAULT_HEARTBEAT_CONFIG } from './heartbeat.js' export type { Heartbeat, HeartbeatConfig, HeartbeatOpts } from './heartbeat.js' -export { parseHeartbeatResponse, isWithinActiveHours, HeartbeatDedup } from './heartbeat.js' +export { isWithinActiveHours, HeartbeatDedup } from './heartbeat.js' diff --git a/src/task/task-router/listener.spec.ts b/src/task/task-router/listener.spec.ts index a7fc8b87..91ca7c45 100644 --- a/src/task/task-router/listener.spec.ts +++ b/src/task/task-router/listener.spec.ts @@ -8,6 +8,8 @@ import { createTaskRouter, type TaskRouter } from './listener.js' import { SessionStore } from '../../core/session.js' import type { TaskRequestedPayload } from '../../core/agent-event.js' import { ConnectorCenter } from '../../core/connector-center.js' +import { createMemoryNotificationsStore } from '../../core/notifications-store.js' +import { AgentWorkRunner } from '../../core/agent-work.js' function tempPath(ext: string): string { return join(tmpdir(), `task-router-test-${randomUUID()}.${ext}`) @@ -45,11 +47,14 @@ describe('task router', () => { registry = createListenerRegistry(eventLog) mockEngine = createMockEngine() session = new SessionStore(`test/task-${randomUUID()}`) - connectorCenter = new ConnectorCenter() + connectorCenter = new ConnectorCenter({ notificationsStore: createMemoryNotificationsStore() }) - taskRouter = createTaskRouter({ - connectorCenter, + const agentWorkRunner = new AgentWorkRunner({ agentCenter: mockEngine as any, + connectorCenter, + }) + taskRouter = createTaskRouter({ + agentWorkRunner, registry, session, }) diff --git a/src/task/task-router/listener.ts b/src/task/task-router/listener.ts index 69c25a20..4ad7470d 100644 --- a/src/task/task-router/listener.ts +++ b/src/task/task-router/listener.ts @@ -1,23 +1,27 @@ /** * Task Router — subscribes to externally-ingested `task.requested` events - * and routes them through the AgentCenter for one-shot processing. + * (POST /api/events/ingest) and submits each as an AgentWork. * * Flow: * POST /api/events/ingest { type: 'task.requested', payload: { prompt } } * → eventLog 'task.requested' - * → agentCenter.askWithSession(prompt, session) - * → connectorCenter.notify(reply) - * → ctx.emit 'task.done' / 'task.error' + * → AgentWorkRunner: AI call → notify → emit task.done / task.error * - * The listener owns a dedicated SessionStore for externally-triggered tasks - * (`task/default`), independent of cron, heartbeat, and chat sessions. + * The listener owns a dedicated SessionStore for externally-triggered + * tasks (`task/default`), independent of cron, heartbeat, and chat + * sessions, so external callers don't accidentally see (or pollute) + * conversation history that wasn't meant for them. + * + * Like cron, this listener does NOT teach Alice about `notify_user` — + * external tasks default to the same "every reply pushes" behaviour. + * A specific external integration that wants AI-decides-to-notify + * semantics would set that up in its own prompt + outputGate. */ import type { EventLogEntry } from '../../core/event-log.js' import type { TaskRequestedPayload } from '../../core/agent-event.js' -import type { AgentCenter } from '../../core/agent-center.js' +import type { AgentWorkRunner } from '../../core/agent-work.js' import { SessionStore } from '../../core/session.js' -import type { ConnectorCenter } from '../../core/connector-center.js' import type { Listener, ListenerContext } from '../../core/listener.js' import type { ListenerRegistry } from '../../core/listener-registry.js' @@ -27,8 +31,7 @@ const TASK_EMITS = ['task.done', 'task.error'] as const type TaskEmits = typeof TASK_EMITS export interface TaskRouterOpts { - connectorCenter: ConnectorCenter - agentCenter: AgentCenter + agentWorkRunner: AgentWorkRunner /** Registry to auto-register this listener with. */ registry: ListenerRegistry /** Optional: inject a session for testing. Otherwise creates `task/default`. */ @@ -47,7 +50,7 @@ export interface TaskRouter { // ==================== Factory ==================== export function createTaskRouter(opts: TaskRouterOpts): TaskRouter { - const { connectorCenter, agentCenter, registry } = opts + const { agentWorkRunner, registry } = opts const session = opts.session ?? new SessionStore('task/default') let processing = false @@ -63,42 +66,33 @@ export function createTaskRouter(opts: TaskRouterOpts): TaskRouter { ): Promise { const payload = entry.payload - // Guard: skip if already processing (serial execution, same as cron-router) if (processing) { console.warn(`task-router: skipping (already processing)`) return } processing = true - const startMs = Date.now() - try { - const result = await agentCenter.askWithSession(payload.prompt, session, { - historyPreamble: `You are handling an externally-triggered task (session: task/default). Follow the prompt and reply with what the caller needs.`, - }) - - try { - await connectorCenter.notify(result.text, { - media: result.media, - source: 'task', - }) - } catch (sendErr) { - console.warn(`task-router: send failed:`, sendErr) - } - - await ctx.emit('task.done', { - prompt: payload.prompt, - reply: result.text, - durationMs: Date.now() - startMs, - }) - } catch (err) { - console.error(`task-router: error:`, err) - - await ctx.emit('task.error', { - prompt: payload.prompt, - error: err instanceof Error ? err.message : String(err), - durationMs: Date.now() - startMs, - }) + await agentWorkRunner.run( + { + prompt: payload.prompt, + session, + preamble: `You are handling an externally-triggered task (session: task/default). Follow the prompt and reply with what the caller needs.`, + metadata: { source: 'task', prompt: payload.prompt }, + emitNames: { done: 'task.done', error: 'task.error' }, + buildDonePayload: (req, result, durationMs) => ({ + prompt: req.metadata.prompt as string, + reply: result.text, + durationMs, + }), + buildErrorPayload: (req, err, durationMs) => ({ + prompt: req.metadata.prompt as string, + error: err.message, + durationMs, + }), + }, + ctx.emit as never, + ) } finally { processing = false } From d7711887a19629414698678827a79a405797f17d Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 11 May 2026 11:11:59 +0800 Subject: [PATCH 04/12] fix(broker/ccxt): refuse partial market load on init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If fetchMarkets(type) exhausted retries the wrapper used to log "— skipping" and let init() complete with a partial market catalog. That left the broker permanently degraded: every getAccount() understated netLiquidation by every spot/derivative holding whose market never loaded, and the per-tick "spot holding BTC — no /USDT|USDC|USD spot market, skipping" warning was the only signal. A CCXT account is a full-spectrum interface; whether the user actively trades a type is their choice, not the broker's to silently shed. Now the wrapper throws on terminal failure so init() fails loud, UTA marks the account unhealthy, and snapshots are never written from a half-loaded broker. Also wraps the per-type fetch in try/finally to restore the original fmOpts['types'] — the old code only restored on the success path, so a mid-loop throw or refreshCatalog() retry inherited a polluted [singleType] filter. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/domain/trading/brokers/ccxt/CcxtBroker.ts | 57 ++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index f23133c4..3fcfa66e 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -226,34 +226,51 @@ export class CcxtBroker implements IBroker { // Use the exchange's own default types (set in its CCXT class describe()). // Skip 'option' type — option markets are typically thousands of contracts // (Bybit alone has ~10k+) and rarely useful for automated trading. - const allTypes = (fmOpts['types'] ?? []) as string[] + const originalTypes = fmOpts['types'] + const allTypes = (originalTypes ?? []) as string[] const types = allTypes.length > 0 ? allTypes.filter(t => t !== 'option') : ['spot', 'linear', 'inverse'] // fallback for exchanges that don't declare types - const allMarkets: unknown[] = [] - for (const type of types) { - for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) { - try { - const prevTypes = fmOpts['types'] - fmOpts['types'] = [type] - const markets = await origFetchMarkets(params) - fmOpts['types'] = prevTypes - allMarkets.push(...markets) - break - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - if (attempt < MAX_INIT_RETRIES) { - const delay = INIT_RETRY_BASE_MS * Math.pow(2, attempt - 1) - console.warn(`CcxtBroker[${accountId}]: fetchMarkets(${type}) attempt ${attempt}/${MAX_INIT_RETRIES} failed, retrying in ${delay}ms...`) - await new Promise(r => setTimeout(r, delay)) - } else { - console.warn(`CcxtBroker[${accountId}]: fetchMarkets(${type}) failed after ${MAX_INIT_RETRIES} attempts: ${msg} — skipping`) + try { + const allMarkets: unknown[] = [] + for (const type of types) { + let lastErr: unknown + let success = false + for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) { + try { + fmOpts['types'] = [type] + const markets = await origFetchMarkets(params) + allMarkets.push(...markets) + success = true + break + } catch (err) { + lastErr = err + if (attempt < MAX_INIT_RETRIES) { + const delay = INIT_RETRY_BASE_MS * Math.pow(2, attempt - 1) + const msg = err instanceof Error ? err.message : String(err) + console.warn(`CcxtBroker[${accountId}]: fetchMarkets(${type}) attempt ${attempt}/${MAX_INIT_RETRIES} failed, retrying in ${delay}ms... (${msg.slice(0, 160)})`) + await new Promise(r => setTimeout(r, delay)) + } } } + if (!success) { + // A CCXT account is a full-spectrum interface — every market type + // the exchange supports must load, or the broker refuses to come + // up. Silently dropping a type (e.g. spot) would understate + // netLiquidation and hide real holdings, producing wrong snapshots + // forever until process restart. Whether the user actively trades + // that type is their decision, not ours. + const msg = lastErr instanceof Error ? lastErr.message : String(lastErr) + throw new Error( + `CcxtBroker[${accountId}]: fetchMarkets(${type}) failed after ${MAX_INIT_RETRIES} attempts: ${msg}`, + ) + } } + return allMarkets as Awaited> + } finally { + fmOpts['types'] = originalTypes } - return allMarkets as Awaited> } try { From 7abeedfcb6ccc75c1d57ffb81944dea712cb8bab Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 11 May 2026 12:49:05 +0800 Subject: [PATCH 05/12] feat(core): add canonical agent.work.* events for AgentWork dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces 4 new event types to AgentEventMap: - agent.work.requested (external — webhook ingestable) - agent.work.done - agent.work.skip - agent.work.error All four carry a `source: NotificationSource` field as their routing key. Consumers that care about a specific trigger source filter on this field instead of subscribing to a separate event type per source — eliminates "event explosion" as the number of trigger sources grows. Old per-source event types (cron.done/cron.error, heartbeat.done/.skip/.error, task.requested/task.done/task.error) stay in this commit; the migration to canonical events lands in followup commits as a single atomic cutover. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/agent-event.spec.ts | 132 +++++++++++++----------- src/core/agent-event.ts | 190 +++++++++++++++-------------------- 2 files changed, 152 insertions(+), 170 deletions(-) diff --git a/src/core/agent-event.spec.ts b/src/core/agent-event.spec.ts index ff4c0e36..ea522ccb 100644 --- a/src/core/agent-event.spec.ts +++ b/src/core/agent-event.spec.ts @@ -6,10 +6,9 @@ import type { AgentEventMap } from './agent-event.js' describe('AgentEventSchemas', () => { const expectedTypes: (keyof AgentEventMap)[] = [ - 'cron.fire', 'cron.done', 'cron.error', - 'heartbeat.done', 'heartbeat.skip', 'heartbeat.error', + 'cron.fire', 'message.received', 'message.sent', - 'task.requested', 'task.done', 'task.error', + 'agent.work.requested', 'agent.work.done', 'agent.work.skip', 'agent.work.error', ] it('should have a schema for every key in AgentEventMap', () => { @@ -46,93 +45,95 @@ describe('validateEventPayload', () => { })).toThrow(/Invalid payload.*cron\.fire/) }) - // -- cron.done -- - it('should accept valid cron.done payload', () => { - expect(() => validateEventPayload('cron.done', { - jobId: 'abc', jobName: 'test', reply: 'ok', durationMs: 100, + // -- message.received -- + it('should accept valid message.received payload', () => { + expect(() => validateEventPayload('message.received', { + channel: 'web', to: 'default', prompt: 'hello', })).not.toThrow() }) - // -- cron.error -- - it('should accept valid cron.error payload', () => { - expect(() => validateEventPayload('cron.error', { - jobId: 'abc', jobName: 'test', error: 'boom', durationMs: 50, + // -- message.sent -- + it('should accept valid message.sent payload', () => { + expect(() => validateEventPayload('message.sent', { + channel: 'web', to: 'default', prompt: 'hello', reply: 'hi', durationMs: 300, })).not.toThrow() }) - // -- heartbeat.done -- - it('should accept valid heartbeat.done payload', () => { - expect(() => validateEventPayload('heartbeat.done', { - reply: 'all good', reason: 'CHAT_YES', durationMs: 200, delivered: true, - })).not.toThrow() + it('should reject message.sent with missing reply', () => { + expect(() => validateEventPayload('message.sent', { + channel: 'web', to: 'default', prompt: 'hello', durationMs: 300, + })).toThrow(/Invalid payload.*message\.sent/) }) - // -- heartbeat.skip -- - it('should accept heartbeat.skip with optional parsedReason', () => { - expect(() => validateEventPayload('heartbeat.skip', { - reason: 'ack', parsedReason: 'All systems normal.', + // -- agent.work.requested -- + it('should accept valid agent.work.requested payload', () => { + expect(() => validateEventPayload('agent.work.requested', { + source: 'task', + prompt: 'investigate', })).not.toThrow() }) - it('should accept heartbeat.skip without parsedReason', () => { - expect(() => validateEventPayload('heartbeat.skip', { - reason: 'outside-active-hours', + it('should accept agent.work.requested with metadata', () => { + expect(() => validateEventPayload('agent.work.requested', { + source: 'cron', + prompt: 'check market', + metadata: { jobId: 'abc', jobName: 'daily' }, })).not.toThrow() }) - it('should reject heartbeat.skip with missing reason', () => { - expect(() => validateEventPayload('heartbeat.skip', { - parsedReason: 'something', - })).toThrow(/Invalid payload.*heartbeat\.skip/) + it('should reject agent.work.requested with unknown source', () => { + expect(() => validateEventPayload('agent.work.requested', { + source: 'bogus', + prompt: 'x', + })).toThrow(/Invalid payload.*agent\.work\.requested/) }) - // -- heartbeat.error -- - it('should accept valid heartbeat.error payload', () => { - expect(() => validateEventPayload('heartbeat.error', { - error: 'timeout', durationMs: 5000, - })).not.toThrow() + it('should reject agent.work.requested without prompt', () => { + expect(() => validateEventPayload('agent.work.requested', { + source: 'task', + })).toThrow(/Invalid payload.*agent\.work\.requested/) }) - // -- message.received -- - it('should accept valid message.received payload', () => { - expect(() => validateEventPayload('message.received', { - channel: 'web', to: 'default', prompt: 'hello', + // -- agent.work.done -- + it('should accept valid agent.work.done payload', () => { + expect(() => validateEventPayload('agent.work.done', { + source: 'heartbeat', + reply: 'BTC alert', + durationMs: 200, + delivered: true, })).not.toThrow() }) - // -- message.sent -- - it('should accept valid message.sent payload', () => { - expect(() => validateEventPayload('message.sent', { - channel: 'web', to: 'default', prompt: 'hello', reply: 'hi', durationMs: 300, - })).not.toThrow() + it('should reject agent.work.done with missing delivered field', () => { + expect(() => validateEventPayload('agent.work.done', { + source: 'heartbeat', + reply: 'x', + durationMs: 100, + })).toThrow(/Invalid payload.*agent\.work\.done/) }) - it('should reject message.sent with missing reply', () => { - expect(() => validateEventPayload('message.sent', { - channel: 'web', to: 'default', prompt: 'hello', durationMs: 300, - })).toThrow(/Invalid payload.*message\.sent/) - }) - - // -- task.* -- - it('should accept valid task.requested payload', () => { - expect(() => validateEventPayload('task.requested', { - prompt: 'check overnight moves', + // -- agent.work.skip -- + it('should accept valid agent.work.skip payload', () => { + expect(() => validateEventPayload('agent.work.skip', { + source: 'heartbeat', + reason: 'outside-active-hours', })).not.toThrow() }) - it('should reject task.requested without prompt', () => { - expect(() => validateEventPayload('task.requested', {})).toThrow(/Invalid payload.*task\.requested/) - }) - - it('should accept valid task.done payload', () => { - expect(() => validateEventPayload('task.done', { - prompt: 'hi', reply: 'ok', durationMs: 120, + it('should accept agent.work.skip with arbitrary metadata', () => { + expect(() => validateEventPayload('agent.work.skip', { + source: 'heartbeat', + reason: 'duplicate', + metadata: { parsedReason: 'BTC alert (first 80 chars)' }, })).not.toThrow() }) - it('should accept valid task.error payload', () => { - expect(() => validateEventPayload('task.error', { - prompt: 'hi', error: 'boom', durationMs: 50, + // -- agent.work.error -- + it('should accept valid agent.work.error payload', () => { + expect(() => validateEventPayload('agent.work.error', { + source: 'cron', + error: 'AI down', + durationMs: 5, })).not.toThrow() }) @@ -146,4 +147,13 @@ describe('validateEventPayload', () => { it('should pass for unregistered type with null payload', () => { expect(() => validateEventPayload('unknown.type', null)).not.toThrow() }) + + // -- legacy types (now removed from internal map but accepted on webhook wire) -- + it('legacy task.requested type is no longer in AgentEventMap', () => { + // The webhook layer handles wire-level legacy alias translation. + // Validation against the canonical type happens after translation. + expect(AgentEventSchemas).not.toHaveProperty('task.requested') + expect(AgentEventSchemas).not.toHaveProperty('heartbeat.done') + expect(AgentEventSchemas).not.toHaveProperty('cron.done') + }) }) diff --git a/src/core/agent-event.ts b/src/core/agent-event.ts index b50c9f78..29b368f0 100644 --- a/src/core/agent-event.ts +++ b/src/core/agent-event.ts @@ -16,43 +16,13 @@ import { Type, type TSchema } from '@sinclair/typebox' import AjvPkg from 'ajv' +import type { NotificationSource } from './notifications-store.js' // Re-export CronFirePayload from its canonical location export type { CronFirePayload } from '../task/cron/engine.js' // ==================== Payload Interfaces ==================== -export interface CronDonePayload { - jobId: string - jobName: string - reply: string - durationMs: number -} - -export interface CronErrorPayload { - jobId: string - jobName: string - error: string - durationMs: number -} - -export interface HeartbeatDonePayload { - reply: string - reason: string - durationMs: number - delivered: boolean -} - -export interface HeartbeatSkipPayload { - reason: string - parsedReason?: string -} - -export interface HeartbeatErrorPayload { - error: string - durationMs: number -} - export interface MessageReceivedPayload { channel: string to: string @@ -67,20 +37,49 @@ export interface MessageSentPayload { durationMs: number } -export interface TaskRequestedPayload { +// ==================== Canonical AgentWork events ==================== +// +// All "Alice runs an async task" flows funnel through these four +// canonical events instead of per-trigger-source event types. The +// `source` field on each payload is the routing key — consumers +// filter on it instead of subscribing to separate event types. +// +// `agent.work.requested` is externally-ingestable (webhook). The +// done/skip/error events are internal-only. + +export interface AgentWorkRequestedPayload { + /** Which trigger source produced this work request. Drives the + * agent-work-listener's source-registry lookup. */ + source: NotificationSource + /** The AI prompt to execute. */ prompt: string + /** Trigger-specific metadata, surfaced back on the canonical + * done/skip/error events via per-source payload builders. */ + metadata?: Record } -export interface TaskDonePayload { - prompt: string +export interface AgentWorkDonePayload { + source: NotificationSource reply: string durationMs: number + /** Did the notification actually reach the connector? */ + delivered: boolean + metadata?: Record } -export interface TaskErrorPayload { - prompt: string +export interface AgentWorkSkipPayload { + source: NotificationSource + /** Free-form reason — e.g. 'ack' | 'duplicate' | 'empty' | + * 'outside-active-hours' | per-source extension. */ + reason: string + metadata?: Record +} + +export interface AgentWorkErrorPayload { + source: NotificationSource error: string durationMs: number + metadata?: Record } // ==================== Event Map ==================== @@ -90,16 +89,12 @@ import type { CronFirePayload } from '../task/cron/engine.js' export interface AgentEventMap { 'cron.fire': CronFirePayload - 'cron.done': CronDonePayload - 'cron.error': CronErrorPayload - 'heartbeat.done': HeartbeatDonePayload - 'heartbeat.skip': HeartbeatSkipPayload - 'heartbeat.error': HeartbeatErrorPayload 'message.received': MessageReceivedPayload 'message.sent': MessageSentPayload - 'task.requested': TaskRequestedPayload - 'task.done': TaskDonePayload - 'task.error': TaskErrorPayload + 'agent.work.requested': AgentWorkRequestedPayload + 'agent.work.done': AgentWorkDonePayload + 'agent.work.skip': AgentWorkSkipPayload + 'agent.work.error': AgentWorkErrorPayload } // ==================== TypeBox Schemas ==================== @@ -110,37 +105,6 @@ const CronFireSchema = Type.Object({ payload: Type.String(), }) -const CronDoneSchema = Type.Object({ - jobId: Type.String(), - jobName: Type.String(), - reply: Type.String(), - durationMs: Type.Number(), -}) - -const CronErrorSchema = Type.Object({ - jobId: Type.String(), - jobName: Type.String(), - error: Type.String(), - durationMs: Type.Number(), -}) - -const HeartbeatDoneSchema = Type.Object({ - reply: Type.String(), - reason: Type.String(), - durationMs: Type.Number(), - delivered: Type.Boolean(), -}) - -const HeartbeatSkipSchema = Type.Object({ - reason: Type.String(), - parsedReason: Type.Optional(Type.String()), -}) - -const HeartbeatErrorSchema = Type.Object({ - error: Type.String(), - durationMs: Type.Number(), -}) - const MessageReceivedSchema = Type.Object({ channel: Type.String(), to: Type.String(), @@ -155,20 +119,44 @@ const MessageSentSchema = Type.Object({ durationMs: Type.Number(), }) -const TaskRequestedSchema = Type.Object({ +// ---- Canonical agent-work event schemas ---- +// +// `source` is constrained to the NotificationSource union literal set. +// Free-form `metadata` is `unknown` at validation time (downstream +// shape decided per-source). + +const SourceUnion = Type.Union([ + Type.Literal('heartbeat'), + Type.Literal('cron'), + Type.Literal('task'), + Type.Literal('manual'), +]) + +const AgentWorkRequestedSchema = Type.Object({ + source: SourceUnion, prompt: Type.String(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), }) -const TaskDoneSchema = Type.Object({ - prompt: Type.String(), +const AgentWorkDoneSchema = Type.Object({ + source: SourceUnion, reply: Type.String(), durationMs: Type.Number(), + delivered: Type.Boolean(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), }) -const TaskErrorSchema = Type.Object({ - prompt: Type.String(), +const AgentWorkSkipSchema = Type.Object({ + source: SourceUnion, + reason: Type.String(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), +}) + +const AgentWorkErrorSchema = Type.Object({ + source: SourceUnion, error: Type.String(), durationMs: Type.Number(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), }) // ==================== AgentEvents — metadata registry ==================== @@ -190,26 +178,6 @@ export const AgentEvents: { [K in keyof AgentEventMap]: AgentEventMeta } = { schema: CronFireSchema, description: 'Cron scheduler timer fired for a registered job.', }, - 'cron.done': { - schema: CronDoneSchema, - description: 'Cron job was routed through the AI and completed successfully.', - }, - 'cron.error': { - schema: CronErrorSchema, - description: 'Cron job routing through the AI failed.', - }, - 'heartbeat.done': { - schema: HeartbeatDoneSchema, - description: 'Heartbeat produced content and (attempted to) deliver a notification.', - }, - 'heartbeat.skip': { - schema: HeartbeatSkipSchema, - description: 'Heartbeat fired but no notification was sent (HEARTBEAT_OK, duplicate, outside active hours, or empty).', - }, - 'heartbeat.error': { - schema: HeartbeatErrorSchema, - description: 'Heartbeat invocation errored.', - }, 'message.received': { schema: MessageReceivedSchema, description: 'A user message arrived on a connector (Web chat, Telegram, etc.).', @@ -218,18 +186,22 @@ export const AgentEvents: { [K in keyof AgentEventMap]: AgentEventMeta } = { schema: MessageSentSchema, description: 'An assistant reply was dispatched on a connector.', }, - 'task.requested': { - schema: TaskRequestedSchema, + 'agent.work.requested': { + schema: AgentWorkRequestedSchema, external: true, - description: 'External caller asked Alice to run a one-shot task with the given prompt. Ingestible via POST /api/events/ingest.', + description: 'Canonical request to dispatch an AgentWork task. Carries `source` (which trigger produced it) plus the AI prompt. Ingestible via POST /api/events/ingest; the webhook layer also accepts the legacy `task.requested` event type and translates it to this canonical form.', + }, + 'agent.work.done': { + schema: AgentWorkDoneSchema, + description: 'An AgentWork task completed and its reply was dispatched. Filter on payload.source to attribute to a specific trigger (heartbeat / cron / task).', }, - 'task.done': { - schema: TaskDoneSchema, - description: 'A requested task completed and its reply was dispatched.', + 'agent.work.skip': { + schema: AgentWorkSkipSchema, + description: 'An AgentWork task was suppressed before delivery (dedup, empty content, outside active hours, AI declined to notify, …). Filter on payload.source for trigger attribution.', }, - 'task.error': { - schema: TaskErrorSchema, - description: 'A requested task failed during execution.', + 'agent.work.error': { + schema: AgentWorkErrorSchema, + description: 'An AgentWork task failed during execution. Filter on payload.source for trigger attribution.', }, } From d1169a15ffa836781e7d21266ecf8a7ba57804d1 Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 11 May 2026 12:49:32 +0800 Subject: [PATCH 06/12] feat(core): agent-work-listener with source registry + 17 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single dispatch point for `agent.work.requested` events. Each trigger source (heartbeat / cron / webhook / future) registers an `AgentWorkSourceConfig` carrying its session, preamble builder, and output-side gates; the listener routes incoming events to the matching config based on `payload.source` and runs the AgentWorkRunner pipeline. Emit names are fixed canonical: agent.work.{done,skip,error}. Each emitted payload bakes the source field in, so downstream consumers (Diary, etc.) filter on source instead of subscribing to per-source event types. Test coverage in agent-work-listener.spec.ts (17 tests): - Source registry: empty start, register, list, overwrite semantics - Dispatch by source field — correct config invoked - Canonical event emission with source baked in - Metadata threading: payload → preamble → done payload - buildDoneMetadata override of default passthrough - Unknown source: silent drop + warning, no events emitted - notify_user-style outputGate idiom end-to-end (delivers tool args) - Skip emission when outputGate returns skip - onDelivered hook fires only on successful delivery - Errors propagate as agent.work.error with source attribution - buildErrorMetadata override - Multi-source independence (concurrent dispatch) - Lifecycle (stop() unsubscribes cleanly) No consumers wired yet — followup commit migrates heartbeat / cron / webhook to emit the canonical event. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/agent-work-listener.spec.ts | 420 +++++++++++++++++++++++++++ src/core/agent-work-listener.ts | 179 ++++++++++++ 2 files changed, 599 insertions(+) create mode 100644 src/core/agent-work-listener.spec.ts create mode 100644 src/core/agent-work-listener.ts diff --git a/src/core/agent-work-listener.spec.ts b/src/core/agent-work-listener.spec.ts new file mode 100644 index 00000000..9556125e --- /dev/null +++ b/src/core/agent-work-listener.spec.ts @@ -0,0 +1,420 @@ +/** + * AgentWorkListener — comprehensive coverage of the canonical dispatch + * point for `agent.work.requested` events. + * + * Covers: source registry, dispatch by source field, canonical event + * emission with source + metadata, unknown-source drop behaviour, + * per-source gate application, multi-source independence. + * + * The AgentWorkRunner itself is covered in agent-work.spec.ts; this + * file tests the listener that sits in front of it. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { createEventLog, type EventLog } from './event-log.js' +import { createListenerRegistry, type ListenerRegistry } from './listener-registry.js' +import { ConnectorCenter } from './connector-center.js' +import { createMemoryNotificationsStore, type INotificationsStore } from './notifications-store.js' +import { SessionStore } from './session.js' +import { AgentWorkRunner } from './agent-work.js' +import { createAgentWorkListener, type AgentWorkListener } from './agent-work-listener.js' +import type { AgentCenter } from './agent-center.js' +import type { ProviderResult, ToolCallSummary } from '../ai-providers/types.js' +import type { AgentWorkDonePayload, AgentWorkSkipPayload, AgentWorkErrorPayload } from './agent-event.js' + +// ==================== Helpers ==================== + +function tempPath(ext: string): string { + return join(tmpdir(), `agent-work-listener-test-${randomUUID()}.${ext}`) +} + +interface MockAgentCenter { + askWithSession: ReturnType + setResult(result: Partial): void + setShouldThrow(err: Error | null): void + callCount(): number +} + +function createMockAgentCenter(): MockAgentCenter { + let result: ProviderResult = { text: 'mock reply', media: [] } + let shouldThrow: Error | null = null + let calls = 0 + const askWithSession = vi.fn(async () => { + calls++ + if (shouldThrow) throw shouldThrow + return result + }) + return { + askWithSession, + setResult(next) { result = { text: 'mock reply', media: [], ...next } }, + setShouldThrow(err) { shouldThrow = err }, + callCount() { return calls }, + } +} + +// ==================== Test suite ==================== + +describe('AgentWorkListener', () => { + let eventLog: EventLog + let registry: ListenerRegistry + let mockAgent: MockAgentCenter + let store: INotificationsStore + let connectorCenter: ConnectorCenter + let runner: AgentWorkRunner + let listener: AgentWorkListener + + beforeEach(async () => { + eventLog = await createEventLog({ logPath: tempPath('jsonl') }) + registry = createListenerRegistry(eventLog) + await registry.start() + mockAgent = createMockAgentCenter() + store = createMemoryNotificationsStore() + connectorCenter = new ConnectorCenter({ notificationsStore: store }) + runner = new AgentWorkRunner({ + agentCenter: mockAgent as unknown as AgentCenter, + connectorCenter, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + listener = createAgentWorkListener({ + runner, + registry, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + await listener.start() + }) + + afterEach(async () => { + listener.stop() + await registry.stop() + await eventLog._resetForTest() + }) + + // ==================== Source registry ==================== + + describe('source registry', () => { + it('starts empty', () => { + expect(listener.listSources()).toEqual([]) + }) + + it('registers a source', () => { + listener.registerSource({ + source: 'cron', + session: new SessionStore('test/cron'), + preamble: () => 'cron context', + }) + expect(listener.listSources()).toEqual(['cron']) + }) + + it('registers multiple sources', () => { + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: () => 'a' }) + listener.registerSource({ source: 'heartbeat', session: new SessionStore('test/hb'), preamble: () => 'b' }) + listener.registerSource({ source: 'task', session: new SessionStore('test/task'), preamble: () => 'c' }) + expect([...listener.listSources()].sort()).toEqual(['cron', 'heartbeat', 'task']) + }) + + it('re-registering the same source overwrites', () => { + const preamble1 = vi.fn(() => 'first') + const preamble2 = vi.fn(() => 'second') + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: preamble1 }) + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: preamble2 }) + expect(listener.listSources()).toEqual(['cron']) + // Verify the second config is the active one (will be confirmed via dispatch below in a separate test) + }) + }) + + // ==================== Dispatch ==================== + + describe('dispatch by source field', () => { + it('routes to the matching source config', async () => { + const preambleCron = vi.fn(() => 'cron preamble') + const preambleHb = vi.fn(() => 'hb preamble') + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: preambleCron }) + listener.registerSource({ source: 'heartbeat', session: new SessionStore('test/hb'), preamble: preambleHb }) + + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'do cron work', + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + expect(preambleCron).toHaveBeenCalled() + expect(preambleHb).not.toHaveBeenCalled() + }) + + it('emits agent.work.done with the source baked into payload', async () => { + listener.registerSource({ source: 'task', session: new SessionStore('test/task'), preamble: () => 'task' }) + mockAgent.setResult({ text: 'task reply' }) + + await eventLog.append('agent.work.requested', { + source: 'task', + prompt: 'task prompt', + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + expect(done.source).toBe('task') + expect(done.reply).toBe('task reply') + expect(done.delivered).toBe(true) + }) + + it('threads payload metadata through preamble', async () => { + const preamble = vi.fn((meta?: Record) => `job=${meta?.jobName ?? '?'}`) + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble }) + + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'p', + metadata: { jobName: 'daily-report', jobId: 'abc' }, + }) + await vi.waitFor(() => { + expect(preamble).toHaveBeenCalled() + }) + expect(preamble.mock.calls[0][0]).toEqual({ jobName: 'daily-report', jobId: 'abc' }) + }) + + it('passes metadata through to agent.work.done payload via default builder', async () => { + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: () => 'p' }) + + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'p', + metadata: { jobId: 'job-1', jobName: 'daily' }, + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + expect(done.metadata).toEqual({ jobId: 'job-1', jobName: 'daily' }) + }) + + it('per-source buildDoneMetadata overrides the default', async () => { + listener.registerSource({ + source: 'cron', + session: new SessionStore('test/cron'), + preamble: () => 'p', + buildDoneMetadata: () => ({ derived: 'custom' }), + }) + + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'p', + metadata: { jobId: 'job-1' }, + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + expect(done.metadata).toEqual({ derived: 'custom' }) + }) + }) + + // ==================== Unknown source ==================== + + describe('unknown source', () => { + it('drops the event silently and logs a warning', async () => { + const logger = { warn: vi.fn(), error: vi.fn() } + // Re-create listener with our logger + listener.stop() + listener = createAgentWorkListener({ runner, registry, logger }) + await listener.start() + + // No source registered. Send an event. + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'orphan', + }) + + // Wait briefly to ensure handler had a chance to run + await new Promise((r) => setTimeout(r, 50)) + + expect(logger.warn).toHaveBeenCalled() + expect(logger.warn.mock.calls[0][0]).toContain("no source registered") + // No done / skip / error emitted + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(0) + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(0) + expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(0) + }) + }) + + // ==================== Gate application ==================== + + describe('outputGate from source config', () => { + /** notify_user-style gate, exercised end-to-end through the listener. */ + function notifyUserGate(probe: { text: string; media: unknown[]; toolCalls: ReadonlyArray }) { + const call = probe.toolCalls.find((c) => c.name === 'notify_user') + if (!call) return { kind: 'skip' as const, reason: 'ack', payload: {} } + const text = ((call.input ?? {}) as { text?: string }).text ?? '' + return { kind: 'deliver' as const, text, media: probe.media as never } + } + + it('delivers when AI calls notify_user', async () => { + listener.registerSource({ + source: 'heartbeat', + session: new SessionStore('test/hb'), + preamble: () => 'hb', + outputGate: notifyUserGate, + }) + mockAgent.setResult({ + text: 'raw', + toolCalls: [{ id: 't1', name: 'notify_user', input: { text: 'BTC alert' } }], + }) + + await eventLog.append('agent.work.requested', { + source: 'heartbeat', + prompt: 'check market', + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + // The runner delivers via connectorCenter; reply comes from buildDonePayload (result.text not the gate text) + expect(done.source).toBe('heartbeat') + expect(done.delivered).toBe(true) + + // What actually got pushed to the user is the gate's text + const { entries } = await store.read() + expect(entries[0].text).toBe('BTC alert') + }) + + it('skips when outputGate returns skip — emits agent.work.skip', async () => { + listener.registerSource({ + source: 'heartbeat', + session: new SessionStore('test/hb'), + preamble: () => 'hb', + outputGate: notifyUserGate, + }) + mockAgent.setResult({ text: 'no notify intent', toolCalls: [] }) + + await eventLog.append('agent.work.requested', { + source: 'heartbeat', + prompt: 'check market', + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) + }) + + const skip = eventLog.recent({ type: 'agent.work.skip' })[0].payload as AgentWorkSkipPayload + expect(skip.source).toBe('heartbeat') + expect(skip.reason).toBe('ack') + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(0) + }) + + it('onDelivered runs on successful delivery, not on skip', async () => { + const onDelivered = vi.fn() + listener.registerSource({ + source: 'heartbeat', + session: new SessionStore('test/hb'), + preamble: () => 'hb', + outputGate: notifyUserGate, + onDelivered, + }) + + // Skip case — no notify_user call + mockAgent.setResult({ text: '', toolCalls: [] }) + await eventLog.append('agent.work.requested', { source: 'heartbeat', prompt: 'p1' }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) + }) + expect(onDelivered).not.toHaveBeenCalled() + + // Deliver case + mockAgent.setResult({ + text: 'r', + toolCalls: [{ id: 't1', name: 'notify_user', input: { text: 'real alert' } }], + }) + await eventLog.append('agent.work.requested', { source: 'heartbeat', prompt: 'p2' }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + expect(onDelivered).toHaveBeenCalledTimes(1) + expect(onDelivered.mock.calls[0][0]).toBe('real alert') + }) + }) + + // ==================== Errors ==================== + + describe('errors', () => { + it('emits agent.work.error with source on AI failure', async () => { + listener.registerSource({ + source: 'cron', + session: new SessionStore('test/cron'), + preamble: () => 'cron', + }) + mockAgent.setShouldThrow(new Error('AI down')) + + await eventLog.append('agent.work.requested', { source: 'cron', prompt: 'p' }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(1) + }) + + const err = eventLog.recent({ type: 'agent.work.error' })[0].payload as AgentWorkErrorPayload + expect(err.source).toBe('cron') + expect(err.error).toBe('AI down') + }) + + it('per-source buildErrorMetadata flows through to the error payload', async () => { + listener.registerSource({ + source: 'cron', + session: new SessionStore('test/cron'), + preamble: () => 'cron', + buildErrorMetadata: (_req, err) => ({ jobId: 'failed-job', errorClass: err.name }), + }) + mockAgent.setShouldThrow(new Error('explode')) + + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'p', + metadata: { jobId: 'job-99' }, + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(1) + }) + + const err = eventLog.recent({ type: 'agent.work.error' })[0].payload as AgentWorkErrorPayload + expect(err.metadata).toEqual({ jobId: 'failed-job', errorClass: 'Error' }) + }) + }) + + // ==================== Concurrent independent sources ==================== + + describe('multi-source independence', () => { + it('two sources can be active and both work', async () => { + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: () => 'a' }) + listener.registerSource({ source: 'task', session: new SessionStore('test/task'), preamble: () => 'b' }) + + await eventLog.append('agent.work.requested', { source: 'cron', prompt: 'cron work' }) + await eventLog.append('agent.work.requested', { source: 'task', prompt: 'task work' }) + + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(2) + }) + + const done = eventLog.recent({ type: 'agent.work.done' }).map(e => (e.payload as AgentWorkDonePayload).source) + expect(done.sort()).toEqual(['cron', 'task']) + }) + }) + + // ==================== Lifecycle ==================== + + describe('lifecycle', () => { + it('stop() removes the listener from the registry', async () => { + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: () => 'p' }) + listener.stop() + + await eventLog.append('agent.work.requested', { source: 'cron', prompt: 'after stop' }) + await new Promise((r) => setTimeout(r, 50)) + + expect(mockAgent.callCount()).toBe(0) + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(0) + }) + }) +}) diff --git a/src/core/agent-work-listener.ts b/src/core/agent-work-listener.ts new file mode 100644 index 00000000..a832010c --- /dev/null +++ b/src/core/agent-work-listener.ts @@ -0,0 +1,179 @@ +/** + * AgentWorkListener — single dispatch point for all `agent.work.requested` + * events. + * + * Subscribes to one canonical event type; routes each event to the right + * per-source configuration (preamble, session, gates, payload-metadata + * builders) by looking up `payload.source` in a runtime registry. + * + * Each trigger source (heartbeat, cron, webhook) registers its config + * at startup. Adding a new trigger source = registering one more + * `AgentWorkSourceConfig`; no new listener, no new event type. + * + * The runner emits canonical `agent.work.{done,skip,error}` events + * with the source label baked into the payload — downstream consumers + * (Diary, etc.) filter on the source field rather than subscribing + * to per-source event types. + */ + +import type { Listener, ListenerContext } from './listener.js' +import type { ListenerRegistry } from './listener-registry.js' +import type { ISessionStore } from './session.js' +import type { NotificationSource } from './notifications-store.js' +import type { AgentWorkRequest, AgentWorkRunner, AgentWorkSkip } from './agent-work.js' +import type { ProviderResult } from '../ai-providers/types.js' +import type { AgentWorkRequestedPayload } from './agent-event.js' + +// ==================== Source config ==================== + +/** Per-source configuration. Each trigger source registers one of these + * at startup; the listener uses it to build an AgentWorkRequest when + * an event arrives with the matching `source` field. */ +export interface AgentWorkSourceConfig { + source: NotificationSource + /** Session scope for this source. All work from a given source shares + * the same conversation history. */ + session: ISessionStore + /** Build the AI history preamble. Receives the event's `metadata` + * so per-event context (e.g. cron job name) can be threaded in. */ + preamble: (metadata: Record | undefined) => string + /** Optional post-AI gate — decides deliver vs skip (with reason). */ + outputGate?: AgentWorkRequest['outputGate'] + /** Optional bookkeeping callback after a successful delivery. */ + onDelivered?: AgentWorkRequest['onDelivered'] + /** Source-specific metadata to attach to the `agent.work.done` payload. + * Defaults to passing through the request metadata. */ + buildDoneMetadata?: ( + req: AgentWorkRequest, + result: ProviderResult, + ) => Record | undefined + /** Source-specific metadata for `agent.work.skip` payloads. */ + buildSkipMetadata?: ( + req: AgentWorkRequest, + skip: AgentWorkSkip, + ) => Record | undefined + /** Source-specific metadata for `agent.work.error` payloads. */ + buildErrorMetadata?: ( + req: AgentWorkRequest, + err: Error, + ) => Record | undefined +} + +// ==================== Listener types ==================== + +const AGENT_WORK_EMITS = [ + 'agent.work.done', + 'agent.work.skip', + 'agent.work.error', +] as const +type AgentWorkEmits = typeof AGENT_WORK_EMITS + +export interface AgentWorkListenerOpts { + runner: AgentWorkRunner + registry: ListenerRegistry + /** Inject logger for tests (defaults to console). */ + logger?: Pick +} + +export interface AgentWorkListener { + start(): Promise + stop(): void + /** Register a source config. Idempotent on `config.source` — re-registering + * the same source overwrites the previous entry. */ + registerSource(config: AgentWorkSourceConfig): void + /** List registered source labels — surfaced by the Automation Flow UI. */ + listSources(): ReadonlyArray + /** Expose the raw listener for direct testing. */ + readonly listener: Listener<'agent.work.requested', AgentWorkEmits> +} + +// ==================== Factory ==================== + +export function createAgentWorkListener(opts: AgentWorkListenerOpts): AgentWorkListener { + const { runner, registry } = opts + const logger = opts.logger ?? console + const sources = new Map() + let registered = false + + const listener: Listener<'agent.work.requested', AgentWorkEmits> = { + name: 'agent-work-listener', + subscribes: 'agent.work.requested', + emits: AGENT_WORK_EMITS, + async handle( + entry, + ctx: ListenerContext, + ): Promise { + const payload = entry.payload as AgentWorkRequestedPayload + const config = sources.get(payload.source) + if (!config) { + // Unknown source — typically a misconfigured trigger or a + // legitimately-new source whose registration hasn't run yet. + // Don't emit (we don't know which source to attribute to); + // log loudly and drop. + logger.warn( + `agent-work-listener: no source registered for '${payload.source}'; dropping (prompt: ${payload.prompt.slice(0, 60)})`, + ) + return + } + + // Build the AgentWorkRequest from the event + source config. + // emitNames are FIXED canonical — the runner emits agent.work.* + // events regardless of source. The source field is baked into + // each payload via the build*Payload functions below. + const request: AgentWorkRequest = { + prompt: payload.prompt, + session: config.session, + preamble: config.preamble(payload.metadata), + metadata: { source: config.source, ...(payload.metadata ?? {}) }, + outputGate: config.outputGate, + onDelivered: config.onDelivered, + emitNames: { + done: 'agent.work.done', + skip: 'agent.work.skip', + error: 'agent.work.error', + }, + buildDonePayload: (req, result, durationMs, delivered) => ({ + source: config.source, + reply: result.text, + durationMs, + delivered, + metadata: config.buildDoneMetadata?.(req, result) ?? payload.metadata, + }), + buildSkipPayload: (req, skip) => ({ + source: config.source, + reason: skip.reason, + metadata: config.buildSkipMetadata?.(req, skip) ?? payload.metadata, + }), + buildErrorPayload: (req, err, durationMs) => ({ + source: config.source, + error: err.message, + durationMs, + metadata: config.buildErrorMetadata?.(req, err) ?? payload.metadata, + }), + } + + await runner.run(request, ctx.emit as never) + }, + } + + return { + listener, + async start() { + if (registered) return + registry.register(listener) + registered = true + }, + stop() { + if (registered) { + registry.unregister(listener.name) + registered = false + } + }, + registerSource(config) { + sources.set(config.source, config) + }, + listSources() { + return [...sources.keys()] + }, + } +} From 294620566f1b76a879699a1ebee2ced9b2a1d730 Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 11 May 2026 12:50:00 +0800 Subject: [PATCH 07/12] refactor(task): migrate heartbeat/cron to AgentWork emitters; collapse per-source events into canonical; delete task-router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cutover that makes "all upstreams of AgentWork manageable" — three trigger sources now emit a single canonical event type that one dispatch listener consumes, instead of each owning its own listener + event types. Trigger source migration: - heartbeat (src/task/heartbeat/heartbeat.ts): subscribes to cron.fire for __heartbeat__, applies active-hours pre-filter, emits agent.work.requested. Output-side dedup + notify_user inspection move to the source config registered with agent-work-listener. No longer imports AgentWorkRunner directly. - cron (src/task/cron/listener.ts): subscribes to cron.fire for user jobs, emits agent.work.requested with jobId/jobName in metadata. Source config has no gates (default deliver-result behaviour). - webhook (src/webui/routes/events.ts): accepts both agent.work.requested (canonical) AND task.requested (legacy wire alias, translated to canonical before storage) — preserves documented external API per "Don't delete our own exports". - task source config registered inline in main.ts since there's no longer a dedicated task-router module. Event-map cleanup: - Removed from AgentEventMap: cron.done, cron.error, heartbeat.done, heartbeat.skip, heartbeat.error, task.requested, task.done, task.error (8 types collapse to 4 canonical). - All associated payload interfaces, TypeBox schemas, and AgentEvents registry entries removed. - Net event-type budget for the agent-work pipeline: 9 → 4 (cron.fire for cron-engine timer fan-out, plus agent.work.{requested,done,skip,error}). Downstream consumer adaptation: - src/webui/routes/diary.ts: queries agent.work.{done,skip,error} filtered by payload.source === 'heartbeat'. outcomeFromEvent keys on canonical type + reason. parsedReason now read from skip event metadata. Tests: - heartbeat.spec.ts: 28 tests; assertions updated for canonical events (agent.work.skip { source: 'heartbeat', reason: ... } instead of heartbeat.skip { reason }). Anti-regression test added for STATUS-shaped raw text not being parsed. - cron/listener.spec.ts: 8 tests; assertions updated for canonical events with source: 'cron'. - webui/__tests__/diary.spec.ts: fixtures updated to canonical event shapes. - listener-registry.spec.ts: minor — sample emit types in test cases switched from cron.done/heartbeat.done to agent.work.done / message.sent now that the old types are gone. - task-router/* deleted entirely. Module wiring (src/main.ts): - Constructs agent-work-listener once with the runner + registry. - Registers task source inline. - Passes agent-work-listener to createHeartbeat and createCronListener for their source registrations. Full suite: 1632/1632 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/listener-registry.spec.ts | 52 +++--- src/main.ts | 40 ++++- src/task/cron/listener.spec.ts | 129 +++++++-------- src/task/cron/listener.ts | 88 +++++----- src/task/heartbeat/heartbeat.spec.ts | 215 ++++++++++-------------- src/task/heartbeat/heartbeat.ts | 226 ++++++++++---------------- src/task/task-router/index.ts | 2 - src/task/task-router/listener.spec.ts | 147 ----------------- src/task/task-router/listener.ts | 116 ------------- src/webui/__tests__/diary.spec.ts | 60 +++---- src/webui/plugin.ts | 4 +- src/webui/routes/diary.ts | 79 +++++---- src/webui/routes/events.ts | 20 ++- 13 files changed, 432 insertions(+), 746 deletions(-) delete mode 100644 src/task/task-router/index.ts delete mode 100644 src/task/task-router/listener.spec.ts delete mode 100644 src/task/task-router/listener.ts diff --git a/src/core/listener-registry.spec.ts b/src/core/listener-registry.spec.ts index 7b310355..ef92b11c 100644 --- a/src/core/listener-registry.spec.ts +++ b/src/core/listener-registry.spec.ts @@ -223,13 +223,13 @@ describe('ListenerRegistry', () => { registry.register({ name: 'cron-echo', subscribes: 'cron.fire', - emits: ['cron.done'] as const, - async handle(entry, ctx) { - await ctx.emit('cron.done', { - jobId: entry.payload.jobId, - jobName: entry.payload.jobName, + emits: ['agent.work.done'] as const, + async handle(_entry, ctx) { + await ctx.emit('agent.work.done', { + source: 'cron', reply: 'ok', durationMs: 10, + delivered: true, }) }, }) @@ -240,10 +240,10 @@ describe('ListenerRegistry', () => { }) await flush() - const done = eventLog.recent({ type: 'cron.done' }) + const done = eventLog.recent({ type: 'agent.work.done' }) expect(done).toHaveLength(1) expect(done[0].causedBy).toBe(parent.seq) - expect(done[0].payload).toMatchObject({ jobId: 'j1', reply: 'ok' }) + expect(done[0].payload).toMatchObject({ source: 'cron', reply: 'ok' }) }) it('should reject emit of un-declared event type at runtime', async () => { @@ -254,12 +254,12 @@ describe('ListenerRegistry', () => { registry.register({ name: 'naughty', subscribes: 'cron.fire', - emits: ['cron.done'] as const, + emits: ['agent.work.done'] as const, async handle(_entry, ctx) { // Cast past the type system to simulate a misuse await (ctx.emit as unknown as (t: string, p: unknown) => Promise)( - 'heartbeat.done', - { reply: 'x', reason: '', durationMs: 0, delivered: false }, + 'message.sent', + { channel: 'web', to: 'x', prompt: 'p', reply: 'r', durationMs: 0 }, ) }, }) @@ -271,7 +271,7 @@ describe('ListenerRegistry', () => { // Error was caught by registry's error isolation and logged expect(errors.length).toBeGreaterThan(0) const msg = String((errors[0] as unknown[])[1]) - expect(msg).toMatch(/naughty.*heartbeat\.done.*declared emits.*cron\.done/) + expect(msg).toMatch(/naughty.*message\.sent.*declared emits.*agent\.work\.done/) } finally { console.error = origErr } @@ -287,8 +287,8 @@ describe('ListenerRegistry', () => { subscribes: 'cron.fire', async handle(_entry, ctx) { await (ctx.emit as unknown as (t: string, p: unknown) => Promise)( - 'cron.done', - { jobId: '', jobName: '', reply: '', durationMs: 0 }, + 'agent.work.done', + { source: 'cron', reply: '', durationMs: 0, delivered: false }, ) }, }) @@ -309,11 +309,11 @@ describe('ListenerRegistry', () => { registry.register({ name: 'cron-override', subscribes: 'cron.fire', - emits: ['cron.done'] as const, + emits: ['agent.work.done'] as const, async handle(_entry, ctx) { await ctx.emit( - 'cron.done', - { jobId: 'j1', jobName: 'x', reply: 'ok', durationMs: 0 }, + 'agent.work.done', + { source: 'cron', reply: 'ok', durationMs: 0, delivered: true }, { causedBy: 999 }, ) }, @@ -323,7 +323,7 @@ describe('ListenerRegistry', () => { await eventLog.append('cron.fire', { jobId: 'j1', jobName: 'x', payload: 'p' }) await flush() - const [done] = eventLog.recent({ type: 'cron.done' }) + const [done] = eventLog.recent({ type: 'agent.work.done' }) expect(done.causedBy).toBe(999) }) }) @@ -433,12 +433,12 @@ describe('ListenerRegistry', () => { await eventLog.append('cron.fire', { jobId: 'j', jobName: 'x', payload: 'p' }) await eventLog.append('message.received', { channel: 'web', to: 'default', prompt: 'hi' }) - await eventLog.append('heartbeat.skip', { reason: 'test' }) + await eventLog.append('agent.work.skip', { source: 'heartbeat', reason: 'test' }) await flush() expect(seenTypes.has('cron.fire')).toBe(true) expect(seenTypes.has('message.received')).toBe(true) - expect(seenTypes.has('heartbeat.skip')).toBe(true) + expect(seenTypes.has('agent.work.skip')).toBe(true) }) it('should ignore events not in AgentEventMap', async () => { @@ -468,12 +468,12 @@ describe('ListenerRegistry', () => { name: 'router', subscribes: 'cron.fire', emits: '*', - async handle(entry, ctx) { - await ctx.emit('cron.done', { - jobId: entry.payload.jobId, - jobName: entry.payload.jobName, + async handle(_entry, ctx) { + await ctx.emit('agent.work.done', { + source: 'cron', reply: 'ok', durationMs: 1, + delivered: true, }) }, }) @@ -482,7 +482,7 @@ describe('ListenerRegistry', () => { await eventLog.append('cron.fire', { jobId: 'j', jobName: 'x', payload: 'p' }) await flush() - const done = eventLog.recent({ type: 'cron.done' }) + const done = eventLog.recent({ type: 'agent.work.done' }) expect(done).toHaveLength(1) }) @@ -552,8 +552,8 @@ describe('ListenerRegistry', () => { }) await expect( (handle.emit as unknown as (t: string, p: unknown) => Promise)( - 'heartbeat.done', - { jobId: 'j', durationMs: 1 }, + 'agent.work.done', + { source: 'cron', reply: 'x', durationMs: 1, delivered: false }, ), ).rejects.toThrow(/cron-engine.*declared emits/) }) diff --git a/src/main.ts b/src/main.ts index e8201791..a8df8e5e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -48,7 +48,7 @@ import { createEventBus } from './core/event-bus.js' import { createCronEngine, createCronListener, createCronTools } from './task/cron/index.js' import { createHeartbeat } from './task/heartbeat/index.js' import { createMetricsListener } from './task/metrics/index.js' -import { createTaskRouter } from './task/task-router/index.js' +import { createAgentWorkListener } from './core/agent-work-listener.js' import { NewsCollectorStore, NewsCollector } from './domain/news/index.js' import { createNewsArchiveTools } from './tool/news.js' @@ -285,11 +285,39 @@ async function main() { const agentWorkRunner = new AgentWorkRunner({ agentCenter, connectorCenter }) + // ==================== AgentWork Listener (single dispatch point) ==================== + // + // Owns all `agent.work.requested` traffic. Each trigger source + // (cron / heartbeat / webhook) registers its source config and + // emits the canonical event; the listener routes by source field + // and runs the AgentWork pipeline. + + const agentWorkListener = createAgentWorkListener({ + runner: agentWorkRunner, + registry: listenerRegistry, + }) + await agentWorkListener.start() + + // Register the `task` (webhook-triggered) source inline. Unlike + // heartbeat and cron, there's no listener-side wrapper — the + // webhook ingest endpoint emits agent.work.requested directly + // (or translates the legacy task.requested wire format). + const taskSession = new SessionStore('task/default') + await taskSession.restore() + agentWorkListener.registerSource({ + source: 'task', + session: taskSession, + preamble: () => + 'You are handling an externally-triggered task (session: task/default). Follow the prompt and reply with what the caller needs.', + buildDoneMetadata: (req) => ({ prompt: req.prompt }), + buildErrorMetadata: (req) => ({ prompt: req.prompt }), + }) + // ==================== Cron Listener ==================== const cronSession = new SessionStore('cron/default') await cronSession.restore() - const cronListener = createCronListener({ agentWorkRunner, registry: listenerRegistry, session: cronSession }) + const cronListener = createCronListener({ agentWorkListener, registry: listenerRegistry, session: cronSession }) await cronListener.start() // ==================== Snapshot Scheduler ==================== @@ -304,18 +332,14 @@ async function main() { const heartbeat = createHeartbeat({ config: config.heartbeat, - agentWorkRunner, cronEngine, registry: listenerRegistry, + agentWorkListener, cronEngine, registry: listenerRegistry, + session: new SessionStore('heartbeat'), }) await heartbeat.start() if (config.heartbeat.enabled) { console.log(`heartbeat: enabled (every ${config.heartbeat.every})`) } - // ==================== Task Router (external `task.requested` handler) ==================== - - const taskRouter = createTaskRouter({ agentWorkRunner, registry: listenerRegistry }) - await taskRouter.start() - // ==================== Event Metrics (wildcard observer) ==================== const metricsListener = createMetricsListener({ registry: listenerRegistry }) diff --git a/src/task/cron/listener.spec.ts b/src/task/cron/listener.spec.ts index a7f7c09f..d242ced6 100644 --- a/src/task/cron/listener.spec.ts +++ b/src/task/cron/listener.spec.ts @@ -10,6 +10,8 @@ import type { CronFirePayload } from './engine.js' import { ConnectorCenter } from '../../core/connector-center.js' import { createMemoryNotificationsStore } from '../../core/notifications-store.js' import { AgentWorkRunner } from '../../core/agent-work.js' +import { createAgentWorkListener, type AgentWorkListener } from '../../core/agent-work-listener.js' +import type { AgentWorkDonePayload, AgentWorkErrorPayload } from '../../core/agent-event.js' function tempPath(ext: string): string { return join(tmpdir(), `cron-listener-test-${randomUUID()}.${ext}`) @@ -25,13 +27,11 @@ function createMockEngine(response = 'AI reply') { calls, setResponse(text: string) { response = text }, setShouldFail(val: boolean) { shouldFail = val }, - // Partial Engine mock — only askWithSession is needed askWithSession: vi.fn(async (prompt: string, session: SessionStore) => { calls.push({ prompt, session }) if (shouldFail) throw new Error('engine error') return { text: response, media: [] } }), - // Stubs for other Engine methods ask: vi.fn(), } } @@ -40,35 +40,39 @@ describe('cron listener', () => { let eventLog: EventLog let registry: ListenerRegistry let cronListener: CronListener + let agentWorkListener: AgentWorkListener let mockEngine: ReturnType let session: SessionStore - let logPath: string let connectorCenter: ConnectorCenter let notificationsStore: ReturnType beforeEach(async () => { - logPath = tempPath('jsonl') - eventLog = await createEventLog({ logPath }) + eventLog = await createEventLog({ logPath: tempPath('jsonl') }) registry = createListenerRegistry(eventLog) + await registry.start() mockEngine = createMockEngine() session = new SessionStore(`test/cron-${randomUUID()}`) notificationsStore = createMemoryNotificationsStore() connectorCenter = new ConnectorCenter({ notificationsStore }) - const agentWorkRunner = new AgentWorkRunner({ - agentCenter: mockEngine as any, + const runner = new AgentWorkRunner({ + agentCenter: mockEngine as never, connectorCenter, }) + agentWorkListener = createAgentWorkListener({ runner, registry }) + await agentWorkListener.start() + cronListener = createCronListener({ - agentWorkRunner, + agentWorkListener, registry, session, }) await cronListener.start() - await registry.start() }) afterEach(async () => { + cronListener.stop() + agentWorkListener.stop() await registry.stop() await eventLog._resetForTest() }) @@ -76,26 +80,24 @@ describe('cron listener', () => { // ==================== Basic functionality ==================== describe('event handling', () => { - it('should call engine.askWithSession on cron.fire', async () => { + it('emits agent.work.requested on cron.fire', async () => { await eventLog.append('cron.fire', { jobId: 'abc12345', jobName: 'test-job', payload: 'Check the market', } satisfies CronFirePayload) - // Wait for async handler await vi.waitFor(() => { - expect(mockEngine.askWithSession).toHaveBeenCalledTimes(1) + expect(eventLog.recent({ type: 'agent.work.requested' })).toHaveLength(1) }) - expect(mockEngine.askWithSession).toHaveBeenCalledWith( - 'Check the market', - session, - expect.objectContaining({ historyPreamble: expect.any(String) }), - ) + const req = eventLog.recent({ type: 'agent.work.requested' })[0].payload as { source: string; prompt: string; metadata: { jobId: string; jobName: string } } + expect(req.source).toBe('cron') + expect(req.prompt).toBe('Check the market') + expect(req.metadata).toEqual({ jobId: 'abc12345', jobName: 'test-job' }) }) - it('should write cron.done event on success', async () => { + it('downstream agent.work.done payload carries source=cron + reply', async () => { const fireEntry = await eventLog.append('cron.fire', { jobId: 'abc12345', jobName: 'test-job', @@ -103,26 +105,37 @@ describe('cron listener', () => { } satisfies CronFirePayload) await vi.waitFor(() => { - const done = eventLog.recent({ type: 'cron.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) }) - const done = eventLog.recent({ type: 'cron.done' }) - expect(done[0].payload).toMatchObject({ - jobId: 'abc12345', - jobName: 'test-job', - reply: 'AI reply', - }) - expect((done[0].payload as any).durationMs).toBeGreaterThanOrEqual(0) - expect(done[0].causedBy).toBe(fireEntry.seq) + const done = eventLog.recent({ type: 'agent.work.done' })[0] + const payload = done.payload as AgentWorkDonePayload + expect(payload.source).toBe('cron') + expect(payload.reply).toBe('AI reply') + expect(payload.durationMs).toBeGreaterThanOrEqual(0) + expect(payload.delivered).toBe(true) + expect(payload.metadata).toMatchObject({ jobId: 'abc12345', jobName: 'test-job' }) + // causality: done is caused by the requested event, which is caused by fire + expect(typeof done.causedBy).toBe('number') }) - it('should not react to other event types', async () => { - await eventLog.append('some.other.event', { data: 'hello' }) + it('filters out internal __*__ jobs', async () => { + await eventLog.append('cron.fire', { + jobId: 'hb-id', + jobName: '__heartbeat__', + payload: 'should be ignored by cron-router', + } satisfies CronFirePayload) - // Give it a moment await new Promise((r) => setTimeout(r, 50)) + // cron-router didn't emit anything (its filter dropped this) + const requested = eventLog.recent({ type: 'agent.work.requested' }) + expect(requested.filter(e => (e.payload as { source: string }).source === 'cron')).toHaveLength(0) + }) + + it('does not react to other event types', async () => { + await eventLog.append('message.received' as never, { channel: 'web', to: 'x', prompt: 'p' }) + await new Promise((r) => setTimeout(r, 50)) expect(mockEngine.askWithSession).not.toHaveBeenCalled() }) }) @@ -130,9 +143,9 @@ describe('cron listener', () => { // ==================== Delivery ==================== describe('delivery', () => { - it('should append AI reply to the notifications store', async () => { - const delivered: string[] = [] - notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + it('appends AI reply to notifications store with source=cron', async () => { + const delivered: Array<{ text: string; source?: string }> = [] + notificationsStore.onAppended((entry) => { delivered.push({ text: entry.text, source: entry.source }) }) await eventLog.append('cron.fire', { jobId: 'abc12345', @@ -144,62 +157,37 @@ describe('cron listener', () => { expect(delivered).toHaveLength(1) }) - expect(delivered[0]).toBe('AI reply') - - const { entries } = await notificationsStore.read() - expect(entries[0].source).toBe('cron') - }) - - it('should handle notify failure gracefully', async () => { - // Force the underlying append to throw — cron listener must keep - // the loop alive (still emit cron.done). - notificationsStore.append = async () => { throw new Error('store failed') } - - await eventLog.append('cron.fire', { - jobId: 'abc12345', - jobName: 'test-job', - payload: 'Hello', - } satisfies CronFirePayload) - - await vi.waitFor(() => { - const done = eventLog.recent({ type: 'cron.done' }) - expect(done).toHaveLength(1) - }) + expect(delivered[0]).toEqual({ text: 'AI reply', source: 'cron' }) }) }) // ==================== Error handling ==================== describe('error handling', () => { - it('should write cron.error on engine failure', async () => { + it('emits agent.work.error on engine failure', async () => { mockEngine.setShouldFail(true) - const fireEntry = await eventLog.append('cron.fire', { + await eventLog.append('cron.fire', { jobId: 'abc12345', jobName: 'test-job', payload: 'Will fail', } satisfies CronFirePayload) await vi.waitFor(() => { - const errors = eventLog.recent({ type: 'cron.error' }) - expect(errors).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(1) }) - const errors = eventLog.recent({ type: 'cron.error' }) - expect(errors[0].payload).toMatchObject({ - jobId: 'abc12345', - jobName: 'test-job', - error: 'engine error', - }) - expect((errors[0].payload as any).durationMs).toBeGreaterThanOrEqual(0) - expect(errors[0].causedBy).toBe(fireEntry.seq) + const err = eventLog.recent({ type: 'agent.work.error' })[0].payload as AgentWorkErrorPayload + expect(err.source).toBe('cron') + expect(err.error).toBe('engine error') + expect(err.metadata).toMatchObject({ jobId: 'abc12345', jobName: 'test-job' }) }) }) // ==================== Lifecycle ==================== describe('lifecycle', () => { - it('should stop receiving events after registry.stop()', async () => { + it('stops emitting after registry.stop()', async () => { await registry.stop() await eventLog.append('cron.fire', { @@ -208,14 +196,13 @@ describe('cron listener', () => { payload: 'Should not fire', } satisfies CronFirePayload) - // Give it a moment await new Promise((r) => setTimeout(r, 50)) expect(mockEngine.askWithSession).not.toHaveBeenCalled() }) - it('should be idempotent on repeated start()', async () => { - await cronListener.start() // second call — should be a no-op + it('is idempotent on repeated start()', async () => { + await cronListener.start() // No error }) }) diff --git a/src/task/cron/listener.ts b/src/task/cron/listener.ts index bfc0e95e..b46ab776 100644 --- a/src/task/cron/listener.ts +++ b/src/task/cron/listener.ts @@ -1,40 +1,35 @@ /** - * Cron Listener — subscribes to `cron.fire` events and submits each as - * an AgentWork to the runner. The runner owns the AI call → notify → - * emit pipeline; this listener is a thin trigger source that: + * Cron Listener — translates user-defined cron job fires into + * canonical `agent.work.requested` events. The agent-work-listener + * picks them up and runs the AI dispatch pipeline. * - * 1. Filters out internal `__*__` jobs (heartbeat / snapshot have - * their own handlers) - * 2. Enforces serial execution (no overlapping cron handlings) - * 3. Builds an AgentWorkRequest with cron-shaped emit names + done - * payload - * 4. Delegates to `runner.run` + * Filters out internal `__*__` jobs (heartbeat / snapshot have their + * own handlers). Serial-execution lock preserved from previous design + * so concurrent fires don't overlap. * * No notification policy lives here — every successful cron reply is - * pushed (the AgentWork default). If a future cron job wants - * AI-decides-to-notify semantics, its prompt can teach Alice about - * `notify_user` and supply an outputGate; the listener stays unchanged. + * pushed (the AgentWork default). Cron jobs that want + * AI-decides-to-notify semantics can teach their prompt about + * `notify_user`; the source config registered here doesn't reference + * any output gate, so the default deliver-result.text behaviour wins. */ import type { EventLogEntry } from '../../core/event-log.js' import type { CronFirePayload } from '../../core/agent-event.js' -import type { AgentWorkRunner } from '../../core/agent-work.js' import { SessionStore } from '../../core/session.js' import type { Listener, ListenerContext } from '../../core/listener.js' import type { ListenerRegistry } from '../../core/listener-registry.js' +import type { AgentWorkListener, AgentWorkSourceConfig } from '../../core/agent-work-listener.js' -/** Internal jobs (prefixed with __) have dedicated handlers and should not be routed to the AI. */ function isInternalJob(name: string): boolean { return name.startsWith('__') && name.endsWith('__') } -// ==================== Types ==================== - -const CRON_EMITS = ['cron.done', 'cron.error'] as const +const CRON_EMITS = ['agent.work.requested'] as const type CronEmits = typeof CRON_EMITS export interface CronListenerOpts { - agentWorkRunner: AgentWorkRunner + agentWorkListener: AgentWorkListener /** Registry to auto-register this listener with. */ registry: ListenerRegistry /** Optional: inject a session for testing. Otherwise creates a dedicated cron session. */ @@ -42,23 +37,37 @@ export interface CronListenerOpts { } export interface CronListener { - /** Register the listener with the registry (idempotent). */ start(): Promise - /** Unregister the listener from the registry. */ stop(): void - /** Expose the raw Listener object (for testing `handle()` directly). */ readonly listener: Listener<'cron.fire', CronEmits> } -// ==================== Factory ==================== - export function createCronListener(opts: CronListenerOpts): CronListener { - const { agentWorkRunner, registry } = opts + const { agentWorkListener, registry } = opts const session = opts.session ?? new SessionStore('cron/default') let processing = false let registered = false + const sourceConfig: AgentWorkSourceConfig = { + source: 'cron', + session, + preamble: (metadata) => { + const jobName = (metadata as { jobName?: string } | undefined)?.jobName + return `You are operating in the cron job context (session: cron/default${jobName ? `, job: ${jobName}` : ''}). This is an automated cron job execution.` + }, + // No output gate — every successful reply is pushed (default + // AgentWork behaviour matches today's cron semantics). + buildDoneMetadata: (req) => { + const m = req.metadata as { jobId?: string; jobName?: string } + return { jobId: m.jobId, jobName: m.jobName } + }, + buildErrorMetadata: (req) => { + const m = req.metadata as { jobId?: string; jobName?: string } + return { jobId: m.jobId, jobName: m.jobName } + }, + } + const listener: Listener<'cron.fire', CronEmits> = { name: 'cron-router', subscribes: 'cron.fire', @@ -69,10 +78,9 @@ export function createCronListener(opts: CronListenerOpts): CronListener { ): Promise { const payload = entry.payload - // Internal jobs (__heartbeat__, __snapshot__, etc.) have dedicated handlers + // Internal jobs have dedicated handlers (heartbeat / snapshot) if (isInternalJob(payload.jobName)) return - // Serial execution — preserves today's behaviour if (processing) { console.warn(`cron-listener: skipping job ${payload.jobId} (already processing)`) return @@ -80,28 +88,11 @@ export function createCronListener(opts: CronListenerOpts): CronListener { processing = true try { - await agentWorkRunner.run( - { - prompt: payload.payload, - session, - preamble: `You are operating in the cron job context (session: cron/default, job: ${payload.jobName}). This is an automated cron job execution.`, - metadata: { source: 'cron', jobId: payload.jobId, jobName: payload.jobName }, - emitNames: { done: 'cron.done', error: 'cron.error' }, - buildDonePayload: (req, result, durationMs) => ({ - jobId: req.metadata.jobId as string, - jobName: req.metadata.jobName as string, - reply: result.text, - durationMs, - }), - buildErrorPayload: (req, err, durationMs) => ({ - jobId: req.metadata.jobId as string, - jobName: req.metadata.jobName as string, - error: err.message, - durationMs, - }), - }, - ctx.emit as never, - ) + await ctx.emit('agent.work.requested', { + source: 'cron', + prompt: payload.payload, + metadata: { jobId: payload.jobId, jobName: payload.jobName }, + }) } finally { processing = false } @@ -113,6 +104,7 @@ export function createCronListener(opts: CronListenerOpts): CronListener { async start() { if (registered) return registry.register(listener) + agentWorkListener.registerSource(sourceConfig) registered = true }, stop() { diff --git a/src/task/heartbeat/heartbeat.spec.ts b/src/task/heartbeat/heartbeat.spec.ts index e69b7763..2261ebd7 100644 --- a/src/task/heartbeat/heartbeat.spec.ts +++ b/src/task/heartbeat/heartbeat.spec.ts @@ -2,23 +2,18 @@ * Heartbeat tests — exercises the full trigger-source pipeline: * * cron.fire (__heartbeat__) - * → handleFire() + * → heartbeat listener handleFire() + * → active-hours pre-filter (emits agent.work.skip directly if blocked) + * → emits agent.work.requested + * → agent-work-listener (separate test fixture) * → AgentWorkRunner.run() - * → inputGate (active-hours) - * → AI invocation - * → outputGate (notify_user inspection + dedup) - * → connectorCenter.notify (optional) - * → emit done / skip / error + * → notify_user-inspection outputGate (with dedup) + * → emits agent.work.done / .skip / .error * - * The legacy STATUS regex protocol is gone. Heartbeat now signals - * notification intent via the `notify_user` tool — these tests mock - * the AgentCenter result to include or omit the tool call, and assert - * on the resulting events. - * - * AgentWork primitive coverage lives in `src/core/agent-work.spec.ts`; - * this file tests heartbeat-specific behaviours: cron job lifecycle, - * active-hours filtering, dedup window, hot enable/disable, and the - * heartbeat-specific outputGate semantics. + * The legacy STATUS regex protocol is gone; notification intent is + * signalled via the notify_user tool. These tests mock the AgentCenter + * result to include or omit the tool call and assert on canonical + * agent.work.* events with source='heartbeat'. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' @@ -40,9 +35,10 @@ import { SessionStore } from '../../core/session.js' import { ConnectorCenter } from '../../core/connector-center.js' import { createMemoryNotificationsStore } from '../../core/notifications-store.js' import { AgentWorkRunner } from '../../core/agent-work.js' +import { createAgentWorkListener, type AgentWorkListener } from '../../core/agent-work-listener.js' import type { ToolCallSummary } from '../../ai-providers/types.js' +import type { AgentWorkDonePayload, AgentWorkSkipPayload, AgentWorkErrorPayload } from '../../core/agent-event.js' -// Mock writeConfigSection to avoid disk writes in tests vi.mock('../../core/config.js', () => ({ writeConfigSection: vi.fn(async () => ({})), })) @@ -62,10 +58,6 @@ function makeConfig(overrides: Partial = {}): HeartbeatConfig { } // ==================== Mock Engine ==================== -// -// Returns `{ text, media, toolCalls }` from `askWithSession`. The -// runner unwraps these as ProviderResult; toolCalls is what the -// heartbeat outputGate inspects for notify_user invocations. interface MockEngineState { text: string @@ -80,21 +72,14 @@ function createMockEngine(initial: Partial = {}) { shouldThrow: null, ...initial, } - return { state, setNotifyUserCall(text: string) { state.toolCalls = [{ id: randomUUID(), name: 'notify_user', input: { text } }] }, - setNoToolCall() { - state.toolCalls = [] - }, - setRawText(text: string) { - state.text = text - }, - setShouldThrow(err: Error | null) { - state.shouldThrow = err - }, + setNoToolCall() { state.toolCalls = [] }, + setRawText(text: string) { state.text = text }, + setShouldThrow(err: Error | null) { state.shouldThrow = err }, askWithSession: vi.fn(async () => { if (state.shouldThrow) throw state.shouldThrow return { text: state.text, media: [], toolCalls: state.toolCalls } @@ -114,7 +99,7 @@ describe('heartbeat', () => { let session: SessionStore let connectorCenter: ConnectorCenter let notificationsStore: ReturnType - let agentWorkRunner: AgentWorkRunner + let agentWorkListener: AgentWorkListener beforeEach(async () => { const logPath = tempPath('jsonl') @@ -129,14 +114,17 @@ describe('heartbeat', () => { session = new SessionStore(`test/heartbeat-${randomUUID()}`) notificationsStore = createMemoryNotificationsStore() connectorCenter = new ConnectorCenter({ notificationsStore }) - agentWorkRunner = new AgentWorkRunner({ + const runner = new AgentWorkRunner({ agentCenter: mockEngine as never, connectorCenter, }) + agentWorkListener = createAgentWorkListener({ runner, registry: listenerRegistry }) + await agentWorkListener.start() }) afterEach(async () => { heartbeat?.stop() + agentWorkListener.stop() cronEngine.stop() await listenerRegistry.stop() await eventLog._resetForTest() @@ -145,12 +133,11 @@ describe('heartbeat', () => { // ==================== Start / Idempotency ==================== describe('start', () => { - it('should register a cron job on start', async () => { + it('registers a cron job on start', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) - await heartbeat.start() const jobs = cronEngine.list() @@ -159,17 +146,17 @@ describe('heartbeat', () => { expect(jobs[0].schedule).toEqual({ kind: 'every', every: '30m' }) }) - it('should be idempotent (update existing job, not create duplicate)', async () => { + it('idempotent (update existing job, not create duplicate)', async () => { heartbeat = createHeartbeat({ config: makeConfig({ every: '30m' }), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() heartbeat.stop() heartbeat = createHeartbeat({ config: makeConfig({ every: '1h' }), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() @@ -178,10 +165,10 @@ describe('heartbeat', () => { expect(jobs[0].schedule).toEqual({ kind: 'every', every: '1h' }) }) - it('should register disabled job when config.enabled is false', async () => { + it('registers disabled job when config.enabled is false', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() @@ -192,10 +179,10 @@ describe('heartbeat', () => { }) }) - // ==================== Event Handling: notify_user contract ==================== + // ==================== Event Handling ==================== describe('event handling', () => { - it('delivers when AI invokes notify_user', async () => { + it('delivers when AI calls notify_user', async () => { const delivered: string[] = [] notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) @@ -203,44 +190,40 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) }) expect(delivered).toEqual(['BTC dropped 5% to $87,200']) - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done[0].payload).toMatchObject({ - reply: 'BTC dropped 5% to $87,200', - delivered: true, - }) + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + expect(done.source).toBe('heartbeat') + expect(done.delivered).toBe(true) }) it('skips with reason=ack when AI does not call notify_user', async () => { - mockEngine.setRawText('Checked. Nothing notable in the last 30 minutes.') + mockEngine.setRawText('Checked, nothing notable.') mockEngine.setNoToolCall() heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - expect(eventLog.recent({ type: 'heartbeat.skip' })).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) }) - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips[0].payload).toMatchObject({ reason: 'ack' }) - // No notify, no done - expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(0) + const skip = eventLog.recent({ type: 'agent.work.skip' })[0].payload as AgentWorkSkipPayload + expect(skip.source).toBe('heartbeat') + expect(skip.reason).toBe('ack') + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(0) }) it('skips with reason=empty when notify_user.text is blank', async () => { @@ -248,35 +231,33 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - expect(eventLog.recent({ type: 'heartbeat.skip' })).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) }) - expect((eventLog.recent({ type: 'heartbeat.skip' })[0].payload as { reason: string }).reason).toBe('empty') + const skip = eventLog.recent({ type: 'agent.work.skip' })[0].payload as AgentWorkSkipPayload + expect(skip.reason).toBe('empty') }) - it('does NOT regex-parse the AI response — STATUS-shaped text without notify_user is still skipped', async () => { - // Old protocol response — must NOT trigger any notification under - // the new contract. The AI must call the tool to deliver. - mockEngine.setRawText('STATUS: CHAT_YES\nREASON: x\nCONTENT: this should NOT be delivered') + it('does NOT regex-parse STATUS-shaped raw text — anti-regression', async () => { + // Legacy protocol response — must NOT trigger any notification. + mockEngine.setRawText('STATUS: CHAT_YES\nCONTENT: should NOT be delivered') mockEngine.setNoToolCall() heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - expect(eventLog.recent({ type: 'heartbeat.skip' })).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) }) const { entries } = await notificationsStore.read() @@ -286,14 +267,14 @@ describe('heartbeat', () => { it('ignores non-heartbeat cron.fire events', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await eventLog.append('cron.fire', { jobId: 'other-job', jobName: 'check-eth', - payload: 'Check ETH price', + payload: 'Check ETH', }) await new Promise((r) => setTimeout(r, 50)) @@ -304,27 +285,30 @@ describe('heartbeat', () => { // ==================== Active Hours ==================== describe('active hours', () => { - it('skips when outside active hours, without invoking AI', async () => { + it('emits agent.work.skip with reason=outside-active-hours, without invoking AI', async () => { const fakeNow = new Date('2025-06-15T03:00:00').getTime() // 3 AM local heartbeat = createHeartbeat({ config: makeConfig({ activeHours: { start: '09:00', end: '22:00', timezone: 'local' }, }), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, now: () => fakeNow, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - expect(eventLog.recent({ type: 'heartbeat.skip' })).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) }) - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect((skips[0].payload as { reason: string }).reason).toBe('outside-active-hours') + const skip = eventLog.recent({ type: 'agent.work.skip' })[0].payload as AgentWorkSkipPayload + expect(skip.source).toBe('heartbeat') + expect(skip.reason).toBe('outside-active-hours') expect(mockEngine.askWithSession).not.toHaveBeenCalled() + // No agent.work.requested was emitted (pre-emit gate) + const reqs = eventLog.recent({ type: 'agent.work.requested' }) + expect(reqs.filter(e => (e.payload as { source: string }).source === 'heartbeat')).toHaveLength(0) }) }) @@ -339,23 +323,20 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - const jobId = cronEngine.list()[0].id - // First fire — delivered await cronEngine.runNow(jobId) await vi.waitFor(() => { - expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) }) - // Second fire (same notify_user text) — should be deduped await cronEngine.runNow(jobId) await vi.waitFor(() => { - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips.some((s) => (s.payload as { reason: string }).reason === 'duplicate')).toBe(true) + const skips = eventLog.recent({ type: 'agent.work.skip' }) + expect(skips.some(s => (s.payload as AgentWorkSkipPayload).reason === 'duplicate')).toBe(true) }) expect(delivered).toHaveLength(1) @@ -367,22 +348,18 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() const jobId = cronEngine.list()[0].id mockEngine.setNotifyUserCall('First alert') await cronEngine.runNow(jobId) - await vi.waitFor(() => { - expect(delivered).toHaveLength(1) - }) + await vi.waitFor(() => { expect(delivered).toHaveLength(1) }) mockEngine.setNotifyUserCall('Second different alert') await cronEngine.runNow(jobId) - await vi.waitFor(() => { - expect(delivered).toHaveLength(2) - }) + await vi.waitFor(() => { expect(delivered).toHaveLength(2) }) expect(delivered).toEqual(['First alert', 'Second different alert']) }) @@ -391,46 +368,43 @@ describe('heartbeat', () => { // ==================== Error Handling ==================== describe('error handling', () => { - it('emits heartbeat.error on AI failure', async () => { + it('emits agent.work.error on AI failure', async () => { mockEngine.setShouldThrow(new Error('AI down')) heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - expect(eventLog.recent({ type: 'heartbeat.error' })).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(1) }) - const errors = eventLog.recent({ type: 'heartbeat.error' }) - expect(errors[0].payload).toMatchObject({ error: 'AI down' }) + const err = eventLog.recent({ type: 'agent.work.error' })[0].payload as AgentWorkErrorPayload + expect(err.source).toBe('heartbeat') + expect(err.error).toBe('AI down') }) - it('handles notify failure gracefully — emits done with delivered=false', async () => { + it('handles notify failure — emits done with delivered=false', async () => { mockEngine.setNotifyUserCall('alert text') - // Force the underlying append to reject. The runner should still - // emit done with delivered=false; the listener should not crash. const originalAppend = notificationsStore.append.bind(notificationsStore) notificationsStore.append = async () => { throw new Error('store failed') } heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) }) - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect((done[0].payload as { delivered: boolean }).delivered).toBe(false) + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + expect(done.delivered).toBe(false) notificationsStore.append = originalAppend }) @@ -442,7 +416,7 @@ describe('heartbeat', () => { it('stops listening after stop()', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() heartbeat.stop() @@ -454,21 +428,19 @@ describe('heartbeat', () => { }) }) - // ==================== setEnabled / isEnabled ==================== + // ==================== setEnabled ==================== describe('setEnabled', () => { it('enables a previously disabled heartbeat', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - expect(heartbeat.isEnabled()).toBe(false) expect(cronEngine.list()[0].enabled).toBe(false) await heartbeat.setEnabled(true) - expect(heartbeat.isEnabled()).toBe(true) expect(cronEngine.list()[0].enabled).toBe(true) }) @@ -476,14 +448,12 @@ describe('heartbeat', () => { it('disables an enabled heartbeat', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: true }), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - expect(heartbeat.isEnabled()).toBe(true) await heartbeat.setEnabled(false) - expect(heartbeat.isEnabled()).toBe(false) expect(cronEngine.list()[0].enabled).toBe(false) }) @@ -493,7 +463,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await heartbeat.setEnabled(true) @@ -512,16 +482,13 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - agentWorkRunner, cronEngine, registry: listenerRegistry, session, + agentWorkListener, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await heartbeat.setEnabled(true) - await cronEngine.runNow(cronEngine.list()[0].id) - await vi.waitFor(() => { - expect(delivered).toHaveLength(1) - }) + await vi.waitFor(() => { expect(delivered).toHaveLength(1) }) expect(delivered[0]).toBe('after-enable') }) }) @@ -537,16 +504,14 @@ describe('isWithinActiveHours', () => { it('returns true within normal range', () => { const ts = todayAt(15, 0).getTime() expect(isWithinActiveHours( - { start: '09:00', end: '22:00', timezone: 'local' }, - ts, + { start: '09:00', end: '22:00', timezone: 'local' }, ts, )).toBe(true) }) it('returns false outside normal range', () => { const ts = todayAt(3, 0).getTime() expect(isWithinActiveHours( - { start: '09:00', end: '22:00', timezone: 'local' }, - ts, + { start: '09:00', end: '22:00', timezone: 'local' }, ts, )).toBe(false) }) @@ -555,12 +520,10 @@ describe('isWithinActiveHours', () => { { start: '22:00', end: '06:00', timezone: 'local' }, todayAt(23, 0).getTime(), )).toBe(true) - expect(isWithinActiveHours( { start: '22:00', end: '06:00', timezone: 'local' }, todayAt(3, 0).getTime(), )).toBe(true) - expect(isWithinActiveHours( { start: '22:00', end: '06:00', timezone: 'local' }, todayAt(12, 0).getTime(), @@ -600,7 +563,7 @@ describe('HeartbeatDedup', () => { expect(d.isDuplicate('world', 500)).toBe(false) }) - it('exposes lastText (load-bearing for buildDonePayload)', () => { + it('exposes lastText', () => { const d = new HeartbeatDedup() expect(d.lastText).toBeNull() d.record('first', 100) diff --git a/src/task/heartbeat/heartbeat.ts b/src/task/heartbeat/heartbeat.ts index 9978d3eb..ab2d90cb 100644 --- a/src/task/heartbeat/heartbeat.ts +++ b/src/task/heartbeat/heartbeat.ts @@ -1,42 +1,40 @@ /** - * Heartbeat — periodic AI self-check, built on top of the cron engine. + * Heartbeat — periodic Alice self-check. * - * Registers a cron job (`__heartbeat__`) that fires at a configured - * interval. Each fire is submitted to AgentWorkRunner with two gates: + * Today's shape: a trigger source for `agent.work.requested`. Owns + * the `__heartbeat__` cron job lifecycle, the active-hours config, + * and the in-memory dedup window. On each tick: * - * - **inputGate**: active-hours filter — skip without spending tokens - * when outside the configured window - * - **outputGate**: inspect AI's tool calls — if `notify_user` was - * invoked, deliver its `text` arg (after dedup); otherwise skip - * silently with reason='ack' - * - **onDelivered**: record dedup state on successful delivery + * 1. Active-hours pre-filter (inputGate equivalent — but enforced + * pre-emit so we don't pollute the event log with skip events + * when the heartbeat shouldn't have fired in the first place). + * Outside hours → emit `agent.work.skip { source: 'heartbeat', + * reason: 'outside-active-hours' }` directly via ctx.emit. + * 2. Otherwise emit `agent.work.requested { source: 'heartbeat', + * prompt }`. The agent-work-listener picks it up and runs. + * 3. The heartbeat-specific outputGate (dedup + notify_user + * inspection) lives in the source config registered with + * agent-work-listener at startup. The runner-side gate sees + * the AI's tool calls and decides deliver vs skip. * - * Replaces the legacy STATUS regex protocol (`STATUS: HEARTBEAT_OK | - * CHAT_YES + CONTENT: ...`) with structured tool-call signalling. The - * runner-side gate handles dedup before the notification reaches - * connectors, which means duplicate suppression and active-hours - * filtering are uniform across configurations. - * - * Events emitted: - * - heartbeat.done { reply, reason, durationMs, delivered } - * - heartbeat.skip { reason, parsedReason? } - * - heartbeat.error { error, durationMs } - * - * Heartbeat-specific state stays in this module: - * - `HeartbeatDedup` — in-memory 24h window - * - `__heartbeat__` cron job lifecycle (idempotent add/update, - * hot-toggle via setEnabled) - * - active-hours config + tz-aware time-of-day check + * Heartbeat no longer imports AgentWorkRunner directly. State that + * heartbeat owns (HeartbeatDedup, active-hours, cron job lifecycle) + * stays here; AgentWork-pipeline state (session, gates) is registered + * with the listener. */ -import type { AgentWorkRunner, AgentWorkResultProbe } from '../../core/agent-work.js' -import type { Listener } from '../../core/listener.js' +import type { Listener, ListenerContext } from '../../core/listener.js' import type { ListenerRegistry } from '../../core/listener-registry.js' import { SessionStore } from '../../core/session.js' import { writeConfigSection } from '../../core/config.js' import type { CronEngine } from '../cron/engine.js' +import type { AgentWorkListener, AgentWorkSourceConfig } from '../../core/agent-work-listener.js' +import type { AgentWorkResultProbe } from '../../core/agent-work.js' -const HEARTBEAT_EMITS = ['heartbeat.done', 'heartbeat.skip', 'heartbeat.error'] as const +const HEARTBEAT_EMITS = [ + 'agent.work.requested', + 'agent.work.skip', +] as const type HeartbeatEmits = typeof HEARTBEAT_EMITS // ==================== Constants ==================== @@ -78,9 +76,11 @@ In short: export interface HeartbeatOpts { config: HeartbeatConfig - agentWorkRunner: AgentWorkRunner + /** Where to register the heartbeat source config so the agent-work + * pipeline knows how to handle heartbeat-sourced requests. */ + agentWorkListener: AgentWorkListener cronEngine: CronEngine - /** Registry to auto-register the heartbeat listener with. */ + /** Listener registry for the heartbeat's own cron-fire subscriber. */ registry: ListenerRegistry /** Optional: inject a session for testing. */ session?: SessionStore @@ -102,7 +102,7 @@ export interface Heartbeat { // ==================== Factory ==================== export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { - const { config, agentWorkRunner, cronEngine, registry } = opts + const { config, agentWorkListener, cronEngine, registry } = opts const session = opts.session ?? new SessionStore('heartbeat') const now = opts.now ?? Date.now @@ -113,111 +113,78 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { const dedup = new HeartbeatDedup() + // ---- Source config (registered with agent-work-listener) ---- + // + // The output-side semantics (notify_user inspection + dedup gate) + // live here, closing over the dedup instance heartbeat owns. The + // agent-work-listener calls these when an agent.work.requested + // event with source='heartbeat' arrives. + const sourceConfig: AgentWorkSourceConfig = { + source: 'heartbeat', + session, + preamble: () => + 'You are operating in the heartbeat monitoring context (session: heartbeat). The following is the recent heartbeat conversation history.', + outputGate: (probe: AgentWorkResultProbe) => { + const call = probe.toolCalls.find((c) => c.name === 'notify_user') + if (!call) { + return { kind: 'skip', reason: 'ack', payload: { reason: 'ack' } } + } + const text = ((call.input ?? {}) as { text?: string }).text ?? '' + if (!text.trim()) { + return { kind: 'skip', reason: 'empty', payload: { reason: 'empty' } } + } + if (dedup.isDuplicate(text, now())) { + return { + kind: 'skip', + reason: 'duplicate', + payload: { reason: 'duplicate', parsedReason: text.slice(0, 80) }, + } + } + return { kind: 'deliver', text, media: probe.media } + }, + onDelivered: (text) => dedup.record(text, now()), + } + const listener: Listener<'cron.fire', HeartbeatEmits> = { name: 'heartbeat', subscribes: 'cron.fire', emits: HEARTBEAT_EMITS, - async handle(entry, ctx) { + async handle(entry, ctx: ListenerContext) { const payload = entry.payload // Filter to our own cron job if (payload.jobName !== HEARTBEAT_JOB_NAME) return - // Serial — preserve today's behaviour. Concurrent heartbeats would - // be ambiguous wrt dedup state. + // Serial — preserve today's behaviour. Concurrent heartbeats + // would race on dedup state. if (processing) return processing = true const startMs = now() console.log(`heartbeat: firing at ${new Date(startMs).toISOString()}`) try { - const result = await agentWorkRunner.run( - { - prompt: payload.payload, - session, - preamble: - 'You are operating in the heartbeat monitoring context (session: heartbeat). The following is the recent heartbeat conversation history.', - metadata: { source: 'heartbeat' }, - - // ---- inputGate: active-hours guard ---- - inputGate: () => - isWithinActiveHours(config.activeHours, now()) - ? null - : { - reason: 'outside-active-hours', - payload: { reason: 'outside-active-hours' }, - }, - - // ---- outputGate: notify_user inspection + dedup ---- - outputGate: (probe: AgentWorkResultProbe) => { - const call = probe.toolCalls.find((c) => c.name === 'notify_user') - if (!call) { - return { - kind: 'skip', - reason: 'ack', - payload: { reason: 'ack' }, - } - } - const text = ((call.input ?? {}) as { text?: string }).text ?? '' - if (!text.trim()) { - return { - kind: 'skip', - reason: 'empty', - payload: { reason: 'empty' }, - } - } - if (dedup.isDuplicate(text, now())) { - return { - kind: 'skip', - reason: 'duplicate', - payload: { reason: 'duplicate', parsedReason: text.slice(0, 80) }, - } - } - return { kind: 'deliver', text, media: probe.media } - }, - - // ---- onDelivered: record dedup state ---- - onDelivered: (text) => dedup.record(text, now()), - - emitNames: { - done: 'heartbeat.done', - skip: 'heartbeat.skip', - error: 'heartbeat.error', - }, - buildDonePayload: (_req, _result, durationMs, delivered) => { - // Look up what we actually delivered (the text the AI passed - // through notify_user). The runner already invoked notify with - // the gate's chosen text; for the done payload we re-derive it - // from the dedup state — `dedup.lastText` is what we just sent. - const reply = dedup.lastText ?? '' - return { - reply, - reason: 'notify_user', - durationMs, - delivered, - } - }, - buildErrorPayload: (_req, err, durationMs) => ({ - error: err.message, - durationMs, - }), - }, - ctx.emit as never, - ) - - const durationMs = now() - startMs - console.log( - `heartbeat: ${result.outcome}` + - (result.skipReason ? ` reason=${result.skipReason}` : '') + - ` (${durationMs}ms)`, - ) + // ---- Pre-emit gate: active-hours ---- + if (!isWithinActiveHours(config.activeHours, now())) { + await ctx.emit('agent.work.skip', { + source: 'heartbeat', + reason: 'outside-active-hours', + }) + console.log(`heartbeat: skipped (outside-active-hours)`) + return + } + + // ---- Emit canonical request ---- + await ctx.emit('agent.work.requested', { + source: 'heartbeat', + prompt: payload.payload, + }) } finally { processing = false } }, } - /** Ensure the cron job exists and listener is registered (idempotent). */ + /** Ensure the cron job exists and the listener + producer are registered (idempotent). */ async function ensureJobAndListener(): Promise { const existing = cronEngine.list().find((j) => j.name === HEARTBEAT_JOB_NAME) if (existing) { @@ -238,6 +205,7 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { if (!registered) { registry.register(listener) + agentWorkListener.registerSource(sourceConfig) registered = true } } @@ -245,29 +213,19 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { return { listener, async start() { - // Always register job + listener (even if disabled) so setEnabled can toggle later await ensureJobAndListener() }, - stop() { - // Unregister the listener so a subsequent start() re-registers cleanly. - // Don't delete the cron job — it persists for restart recovery. if (registered) { registry.unregister(listener.name) registered = false } }, - async setEnabled(newEnabled: boolean) { enabled = newEnabled - - // Ensure infrastructure exists (handles cold enable when start() was called with disabled) await ensureJobAndListener() - - // Persist to config file await writeConfigSection('heartbeat', { ...config, enabled: newEnabled }) }, - isEnabled() { return enabled }, @@ -294,12 +252,9 @@ export function isWithinActiveHours( const nowMinutes = currentMinutesInTimezone(timezone, nowMs) - // Normal range (e.g. 09:00 → 22:00) if (startMinutes <= endMinutes) { return nowMinutes >= startMinutes && nowMinutes < endMinutes } - - // Overnight range (e.g. 22:00 → 06:00) return nowMinutes >= startMinutes || nowMinutes < endMinutes } @@ -314,11 +269,9 @@ function parseHHMM(s: string): number | null { function currentMinutesInTimezone(tz: string, nowMs?: number): number { const date = nowMs ? new Date(nowMs) : new Date() - if (tz === 'local') { return date.getHours() * 60 + date.getMinutes() } - try { const fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, @@ -338,15 +291,14 @@ function currentMinutesInTimezone(tz: string, nowMs?: number): number { // ==================== Dedup ==================== /** - * Suppress identical heartbeat messages within a time window (default 24h). - * - * In-memory only — restart loses dedup state. Acceptable trade-off: - * heartbeat fires every ~30m by default, so a restart-window - * collision is rare and the cost (one duplicate notification) is low. + * Suppress identical heartbeat notify_user texts within a time window + * (default 24h). In-memory only — restart loses dedup state. Acceptable + * trade-off: heartbeats are coarse-grained (~30m), restart-window + * collisions are rare, single-duplicate cost is low. */ export class HeartbeatDedup { - /** Public for the heartbeat factory's `buildDonePayload` to read the - * most-recently-delivered text without an extra signal channel. */ + /** Public for callers that want to inspect the last-delivered text + * (e.g. for the agent.work.done payload's metadata). */ public lastText: string | null = null private lastSentAt = 0 private windowMs: number diff --git a/src/task/task-router/index.ts b/src/task/task-router/index.ts deleted file mode 100644 index fbb36f95..00000000 --- a/src/task/task-router/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createTaskRouter } from './listener.js' -export type { TaskRouter, TaskRouterOpts } from './listener.js' diff --git a/src/task/task-router/listener.spec.ts b/src/task/task-router/listener.spec.ts deleted file mode 100644 index 91ca7c45..00000000 --- a/src/task/task-router/listener.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { randomUUID } from 'node:crypto' -import { createEventLog, type EventLog } from '../../core/event-log.js' -import { createListenerRegistry, type ListenerRegistry } from '../../core/listener-registry.js' -import { createTaskRouter, type TaskRouter } from './listener.js' -import { SessionStore } from '../../core/session.js' -import type { TaskRequestedPayload } from '../../core/agent-event.js' -import { ConnectorCenter } from '../../core/connector-center.js' -import { createMemoryNotificationsStore } from '../../core/notifications-store.js' -import { AgentWorkRunner } from '../../core/agent-work.js' - -function tempPath(ext: string): string { - return join(tmpdir(), `task-router-test-${randomUUID()}.${ext}`) -} - -function createMockEngine(response = 'AI reply') { - const calls: Array<{ prompt: string; session: SessionStore }> = [] - let shouldFail = false - - return { - calls, - setResponse(text: string) { response = text }, - setShouldFail(val: boolean) { shouldFail = val }, - askWithSession: vi.fn(async (prompt: string, session: SessionStore) => { - calls.push({ prompt, session }) - if (shouldFail) throw new Error('engine error') - return { text: response, media: [] } - }), - ask: vi.fn(), - } -} - -describe('task router', () => { - let eventLog: EventLog - let registry: ListenerRegistry - let taskRouter: TaskRouter - let mockEngine: ReturnType - let session: SessionStore - let connectorCenter: ConnectorCenter - let logPath: string - - beforeEach(async () => { - logPath = tempPath('jsonl') - eventLog = await createEventLog({ logPath }) - registry = createListenerRegistry(eventLog) - mockEngine = createMockEngine() - session = new SessionStore(`test/task-${randomUUID()}`) - connectorCenter = new ConnectorCenter({ notificationsStore: createMemoryNotificationsStore() }) - - const agentWorkRunner = new AgentWorkRunner({ - agentCenter: mockEngine as any, - connectorCenter, - }) - taskRouter = createTaskRouter({ - agentWorkRunner, - registry, - session, - }) - await taskRouter.start() - await registry.start() - }) - - afterEach(async () => { - await registry.stop() - await eventLog._resetForTest() - }) - - describe('event handling', () => { - it('calls agent on task.requested with the provided prompt', async () => { - await eventLog.append('task.requested', { - prompt: 'What is the price of BTC?', - } satisfies TaskRequestedPayload) - - await vi.waitFor(() => { - expect(mockEngine.askWithSession).toHaveBeenCalledTimes(1) - }) - expect(mockEngine.askWithSession).toHaveBeenCalledWith( - 'What is the price of BTC?', - session, - expect.objectContaining({ historyPreamble: expect.any(String) }), - ) - }) - - it('emits task.done with reply on success', async () => { - mockEngine.setResponse('BTC is around $65,000') - await eventLog.append('task.requested', { - prompt: 'BTC price?', - } satisfies TaskRequestedPayload) - - await vi.waitFor(() => { - const done = eventLog.recent({ type: 'task.done' }) - expect(done).toHaveLength(1) - expect((done[0].payload as { reply: string }).reply).toBe('BTC is around $65,000') - expect((done[0].payload as { prompt: string }).prompt).toBe('BTC price?') - }) - }) - - it('emits task.error when the agent throws', async () => { - mockEngine.setShouldFail(true) - const origErr = console.error - console.error = () => {} - - try { - await eventLog.append('task.requested', { - prompt: 'This will fail', - } satisfies TaskRequestedPayload) - - await vi.waitFor(() => { - const err = eventLog.recent({ type: 'task.error' }) - expect(err).toHaveLength(1) - expect((err[0].payload as { error: string }).error).toBe('engine error') - }) - } finally { - console.error = origErr - } - }) - - it('sets causedBy on emitted task.done', async () => { - const fireEntry = await eventLog.append('task.requested', { - prompt: 'hi', - } satisfies TaskRequestedPayload) - - await vi.waitFor(() => { - const done = eventLog.recent({ type: 'task.done' }) - expect(done).toHaveLength(1) - expect(done[0].causedBy).toBe(fireEntry.seq) - }) - }) - }) - - describe('registry integration', () => { - it('registers with the listener registry', () => { - const info = registry.list().find((l) => l.name === 'task-router') - expect(info).toBeDefined() - expect(info?.subscribes).toEqual(['task.requested']) - expect(info?.emits).toEqual(['task.done', 'task.error']) - }) - - it('stop() unregisters it', () => { - taskRouter.stop() - const info = registry.list().find((l) => l.name === 'task-router') - expect(info).toBeUndefined() - }) - }) -}) diff --git a/src/task/task-router/listener.ts b/src/task/task-router/listener.ts deleted file mode 100644 index 4ad7470d..00000000 --- a/src/task/task-router/listener.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Task Router — subscribes to externally-ingested `task.requested` events - * (POST /api/events/ingest) and submits each as an AgentWork. - * - * Flow: - * POST /api/events/ingest { type: 'task.requested', payload: { prompt } } - * → eventLog 'task.requested' - * → AgentWorkRunner: AI call → notify → emit task.done / task.error - * - * The listener owns a dedicated SessionStore for externally-triggered - * tasks (`task/default`), independent of cron, heartbeat, and chat - * sessions, so external callers don't accidentally see (or pollute) - * conversation history that wasn't meant for them. - * - * Like cron, this listener does NOT teach Alice about `notify_user` — - * external tasks default to the same "every reply pushes" behaviour. - * A specific external integration that wants AI-decides-to-notify - * semantics would set that up in its own prompt + outputGate. - */ - -import type { EventLogEntry } from '../../core/event-log.js' -import type { TaskRequestedPayload } from '../../core/agent-event.js' -import type { AgentWorkRunner } from '../../core/agent-work.js' -import { SessionStore } from '../../core/session.js' -import type { Listener, ListenerContext } from '../../core/listener.js' -import type { ListenerRegistry } from '../../core/listener-registry.js' - -// ==================== Types ==================== - -const TASK_EMITS = ['task.done', 'task.error'] as const -type TaskEmits = typeof TASK_EMITS - -export interface TaskRouterOpts { - agentWorkRunner: AgentWorkRunner - /** Registry to auto-register this listener with. */ - registry: ListenerRegistry - /** Optional: inject a session for testing. Otherwise creates `task/default`. */ - session?: SessionStore -} - -export interface TaskRouter { - /** Register the listener with the registry (idempotent). */ - start(): Promise - /** Unregister the listener from the registry. */ - stop(): void - /** Expose the raw Listener object (for testing `handle()` directly). */ - readonly listener: Listener<'task.requested', TaskEmits> -} - -// ==================== Factory ==================== - -export function createTaskRouter(opts: TaskRouterOpts): TaskRouter { - const { agentWorkRunner, registry } = opts - const session = opts.session ?? new SessionStore('task/default') - - let processing = false - let registered = false - - const listener: Listener<'task.requested', TaskEmits> = { - name: 'task-router', - subscribes: 'task.requested', - emits: TASK_EMITS, - async handle( - entry: EventLogEntry, - ctx: ListenerContext, - ): Promise { - const payload = entry.payload - - if (processing) { - console.warn(`task-router: skipping (already processing)`) - return - } - - processing = true - try { - await agentWorkRunner.run( - { - prompt: payload.prompt, - session, - preamble: `You are handling an externally-triggered task (session: task/default). Follow the prompt and reply with what the caller needs.`, - metadata: { source: 'task', prompt: payload.prompt }, - emitNames: { done: 'task.done', error: 'task.error' }, - buildDonePayload: (req, result, durationMs) => ({ - prompt: req.metadata.prompt as string, - reply: result.text, - durationMs, - }), - buildErrorPayload: (req, err, durationMs) => ({ - prompt: req.metadata.prompt as string, - error: err.message, - durationMs, - }), - }, - ctx.emit as never, - ) - } finally { - processing = false - } - }, - } - - return { - listener, - async start() { - if (registered) return - registry.register(listener) - registered = true - }, - stop() { - if (registered) { - registry.unregister(listener.name) - registered = false - } - }, - } -} diff --git a/src/webui/__tests__/diary.spec.ts b/src/webui/__tests__/diary.spec.ts index accaefd7..f6df4795 100644 --- a/src/webui/__tests__/diary.spec.ts +++ b/src/webui/__tests__/diary.spec.ts @@ -1,10 +1,10 @@ /** * Tests for the diary route's event-log → cycle projection. * - * The HTTP endpoint itself is a thin wrapper around `buildDiaryCycles` + - * `session.readActive()` + `toChatHistory()`. The join/filter logic that's - * worth locking in is event-type → outcome classification and reason - * selection. + * Post-AgentWork refactor, diary reads canonical `agent.work.*` events + * (filtered by `source === 'heartbeat'`) instead of the old + * `heartbeat.*` event types. These tests exercise the projection + * functions on synthetic fixtures of the new shape. */ import { describe, it, expect } from 'vitest' @@ -17,33 +17,35 @@ import type { EventLogEntry } from '../../core/event-log.js' // ==================== Fixtures ==================== -const done = (seq: number, ts: number, delivered: boolean, reason = 'test', durationMs = 100): EventLogEntry => ({ - seq, ts, type: 'heartbeat.done', - payload: { reply: 'hi', reason, durationMs, delivered }, +const done = (seq: number, ts: number, delivered: boolean, durationMs = 100): EventLogEntry => ({ + seq, ts, type: 'agent.work.done', + payload: { source: 'heartbeat', reply: 'hi', durationMs, delivered }, }) const skip = (seq: number, ts: number, reason: string, parsedReason?: string): EventLogEntry => ({ - seq, ts, type: 'heartbeat.skip', - payload: parsedReason !== undefined ? { reason, parsedReason } : { reason }, + seq, ts, type: 'agent.work.skip', + payload: parsedReason !== undefined + ? { source: 'heartbeat', reason, metadata: { parsedReason } } + : { source: 'heartbeat', reason }, }) const err = (seq: number, ts: number, error: string, durationMs = 50): EventLogEntry => ({ - seq, ts, type: 'heartbeat.error', - payload: { error, durationMs }, + seq, ts, type: 'agent.work.error', + payload: { source: 'heartbeat', error, durationMs }, }) // ==================== Tests ==================== describe('outcomeFromEvent', () => { const cases: Array<[string, EventLogEntry, DiaryOutcome]> = [ - ['heartbeat.done delivered=true → "delivered"', done(1, 0, true), 'delivered'], - ['heartbeat.done delivered=false → "silent-ok"', done(1, 0, false), 'silent-ok'], - ['heartbeat.skip reason=ack → "silent-ok"', skip(1, 0, 'ack'), 'silent-ok'], - ['heartbeat.skip reason=duplicate → "duplicate"', skip(1, 0, 'duplicate'), 'duplicate'], - ['heartbeat.skip reason=empty → "empty"', skip(1, 0, 'empty'), 'empty'], - ['heartbeat.skip reason=outside-active-hours → "outside-hours"', skip(1, 0, 'outside-active-hours'), 'outside-hours'], - ['heartbeat.skip unknown reason → "silent-ok"', skip(1, 0, 'something-else'), 'silent-ok'], - ['heartbeat.error → "error"', err(1, 0, 'boom'), 'error'], + ['agent.work.done delivered=true → "delivered"', done(1, 0, true), 'delivered'], + ['agent.work.done delivered=false → "silent-ok"', done(1, 0, false), 'silent-ok'], + ['agent.work.skip reason=ack → "silent-ok"', skip(1, 0, 'ack'), 'silent-ok'], + ['agent.work.skip reason=duplicate → "duplicate"', skip(1, 0, 'duplicate'), 'duplicate'], + ['agent.work.skip reason=empty → "empty"', skip(1, 0, 'empty'), 'empty'], + ['agent.work.skip reason=outside-active-hours → "outside-hours"', skip(1, 0, 'outside-active-hours'), 'outside-hours'], + ['agent.work.skip unknown reason → "silent-ok"', skip(1, 0, 'something-else'), 'silent-ok'], + ['agent.work.error → "error"', err(1, 0, 'boom'), 'error'], ] for (const [label, entry, expected] of cases) { it(label, () => expect(outcomeFromEvent(entry)).toBe(expected)) @@ -55,7 +57,7 @@ describe('outcomeFromEvent', () => { }) describe('buildDiaryCycles', () => { - it('surfaces error message as reason for heartbeat.error', () => { + it('surfaces error message as reason for agent.work.error', () => { const cycles = buildDiaryCycles([err(5, 1000, 'network timeout', 250)]) expect(cycles[0]).toMatchObject({ seq: 5, @@ -66,9 +68,10 @@ describe('buildDiaryCycles', () => { }) }) - it('prefers parsedReason over machine reason for skip events', () => { - // parsedReason is the AI's own wording — more useful to show humans than the machine code "ack". - const cycles = buildDiaryCycles([skip(5, 1000, 'ack', 'market is quiet, watching for a breakout')]) + it('prefers parsedReason (metadata) over machine reason for skip events', () => { + // parsedReason is heartbeat's truncated notify_user text on duplicate + // skips — more useful to show humans than the machine code "duplicate". + const cycles = buildDiaryCycles([skip(5, 1000, 'duplicate', 'market is quiet, watching for a breakout')]) expect(cycles[0].reason).toBe('market is quiet, watching for a breakout') }) @@ -88,7 +91,7 @@ describe('buildDiaryCycles', () => { it('includes durationMs for done and error, omits for skip', () => { const cycles = buildDiaryCycles([ - done(1, 0, true, 'x', 150), + done(1, 0, true, 150), skip(2, 0, 'ack'), err(3, 0, 'oops', 42), ]) @@ -97,10 +100,11 @@ describe('buildDiaryCycles', () => { expect(cycles[2].durationMs).toBe(42) }) - it('omits reason for done when the payload reason is empty', () => { - // heartbeat.done stores its reason even on delivered cycles; an empty string - // would render as a blank tag, which is noise. - const cycles = buildDiaryCycles([done(1, 0, true, '', 100)]) + it('done events have no source-specific reason in the canonical payload', () => { + // Unlike the old heartbeat.done which carried a `reason` field, the + // canonical agent.work.done only has source/reply/durationMs/delivered. + // A done cycle therefore has no `reason` in the projected output. + const cycles = buildDiaryCycles([done(1, 0, true, 100)]) expect(cycles[0].reason).toBeUndefined() }) }) diff --git a/src/webui/plugin.ts b/src/webui/plugin.ts index cdd931a1..bcd2d8e8 100644 --- a/src/webui/plugin.ts +++ b/src/webui/plugin.ts @@ -41,7 +41,7 @@ export class WebPlugin implements Plugin { /** SSE clients grouped by channel ID. Default channel: 'default'. */ private sseByChannel = new Map>() private unregisterConnector?: () => void - private ingestProducer?: ProducerHandle + private ingestProducer?: ProducerHandle constructor(private config: WebConfig) {} @@ -89,7 +89,7 @@ export class WebPlugin implements Plugin { // Extend this tuple when adding new `external: true` event types. this.ingestProducer = ctx.listenerRegistry.declareProducer({ name: 'webhook-ingest', - emits: ['task.requested'] as const, + emits: ['agent.work.requested'] as const, }) // ==================== Mount route modules ==================== diff --git a/src/webui/routes/diary.ts b/src/webui/routes/diary.ts index 46bb4177..bdfa0bea 100644 --- a/src/webui/routes/diary.ts +++ b/src/webui/routes/diary.ts @@ -3,11 +3,17 @@ * * This is the "status" surface (as opposed to the Chat "notification" surface): * a passive view of what Alice has been thinking across recent heartbeat cycles, - * including silent HEARTBEAT_OK acknowledgements that never reach Chat. + * including silent acknowledgements that never reach Chat. * * Data sources (joined by timestamp proximity): - * - SessionStore('heartbeat') → full AI turns (prompt, reasoning, tool calls, reply) - * - EventLog heartbeat.{done,skip,error} → outcome metadata (delivered, reason, durationMs) + * - SessionStore('heartbeat') → full AI turns (prompt, reasoning, tool calls, reply) + * - EventLog agent.work.{done,skip,error} filtered by source='heartbeat' + * → outcome metadata (delivered, reason, durationMs) + * + * Heartbeat-attributable events all flow through the canonical AgentWork + * event types now; we filter on `payload.source === 'heartbeat'`. (Pre-AgentWork + * the heartbeat module had its own `heartbeat.*` event types; consolidated + * during the AgentWork upstreams refactor.) * * Deliberately polling-only (no SSE). Heartbeat fires ~every 30min; the overhead * of a persistent subscription is not justified for this frequency. @@ -18,20 +24,20 @@ import type { EngineContext } from '../../core/types.js' import { SessionStore, toChatHistory, type ChatHistoryItem } from '../../core/session.js' import type { EventLogEntry } from '../../core/event-log.js' import type { - HeartbeatDonePayload, - HeartbeatSkipPayload, - HeartbeatErrorPayload, + AgentWorkDonePayload, + AgentWorkSkipPayload, + AgentWorkErrorPayload, } from '../../core/agent-event.js' // ==================== Types ==================== export type DiaryOutcome = - | 'delivered' // heartbeat.done, delivered=true — CHAT_YES pushed to Chat - | 'silent-ok' // heartbeat.done delivered=false, or skip.reason=ack — silent HEARTBEAT_OK - | 'duplicate' // skip.reason=duplicate — same content as recent cycle - | 'empty' // skip.reason=empty — AI produced no content - | 'outside-hours' // skip.reason=outside-active-hours — quiet-hours guard tripped - | 'error' // heartbeat.error — AI call threw + | 'delivered' // agent.work.done, delivered=true, source=heartbeat + | 'silent-ok' // agent.work.done delivered=false, or skip.reason=ack + | 'duplicate' // skip.reason=duplicate + | 'empty' // skip.reason=empty + | 'outside-hours' // skip.reason=outside-active-hours + | 'error' // agent.work.error, source=heartbeat export interface DiaryCycle { seq: number @@ -49,7 +55,7 @@ export interface DiaryHistoryResponse { // ==================== Constants ==================== -const HEARTBEAT_EVENT_TYPES = ['heartbeat.done', 'heartbeat.skip', 'heartbeat.error'] as const +const AGENT_WORK_EVENT_TYPES = ['agent.work.done', 'agent.work.skip', 'agent.work.error'] as const /** Slack when joining session entries to cycles by timestamp — covers cron.fire → session.appendUser → ... → event.append gaps. */ const INCREMENTAL_SLACK_MS = 5_000 @@ -72,13 +78,19 @@ function getHeartbeatSession(): SessionStore { // ==================== Event → cycle mapping ==================== -/** Classify a heartbeat event into a user-visible outcome. */ +/** Type predicate: is this canonical agent-work event a heartbeat one? */ +function isHeartbeatAgentWorkEvent(entry: EventLogEntry): boolean { + const payload = entry.payload as { source?: string } | null | undefined + return payload?.source === 'heartbeat' +} + +/** Classify a heartbeat-sourced agent-work event into a user-visible outcome. */ export function outcomeFromEvent(entry: EventLogEntry): DiaryOutcome { - if (entry.type === 'heartbeat.done') { - return (entry.payload as HeartbeatDonePayload).delivered ? 'delivered' : 'silent-ok' + if (entry.type === 'agent.work.done') { + return (entry.payload as AgentWorkDonePayload).delivered ? 'delivered' : 'silent-ok' } - if (entry.type === 'heartbeat.skip') { - const reason = (entry.payload as HeartbeatSkipPayload).reason + if (entry.type === 'agent.work.skip') { + const reason = (entry.payload as AgentWorkSkipPayload).reason switch (reason) { case 'ack': return 'silent-ok' case 'duplicate': return 'duplicate' @@ -87,7 +99,7 @@ export function outcomeFromEvent(entry: EventLogEntry): DiaryOutcome { default: return 'silent-ok' } } - if (entry.type === 'heartbeat.error') return 'error' + if (entry.type === 'agent.work.error') return 'error' return 'silent-ok' } @@ -98,16 +110,19 @@ export function buildDiaryCycles(events: EventLogEntry[]): DiaryCycle[] { let reason: string | undefined let durationMs: number | undefined - if (e.type === 'heartbeat.done') { - const p = e.payload as HeartbeatDonePayload - reason = p.reason || undefined + if (e.type === 'agent.work.done') { + const p = e.payload as AgentWorkDonePayload durationMs = p.durationMs - } else if (e.type === 'heartbeat.skip') { - const p = e.payload as HeartbeatSkipPayload - // parsedReason is the AI's own wording; prefer it over the machine-facing reason code. - reason = p.parsedReason ?? p.reason - } else if (e.type === 'heartbeat.error') { - const p = e.payload as HeartbeatErrorPayload + // No source-specific reason field — done events just have reply text. + } else if (e.type === 'agent.work.skip') { + const p = e.payload as AgentWorkSkipPayload + // The heartbeat outputGate stuffs the (truncated) notify_user text into + // metadata.parsedReason for duplicate skips. Prefer that over the + // machine-facing reason code. + const parsedReason = (p.metadata as { parsedReason?: string } | undefined)?.parsedReason + reason = parsedReason ?? p.reason + } else if (e.type === 'agent.work.error') { + const p = e.payload as AgentWorkErrorPayload reason = p.error durationMs = p.durationMs } @@ -136,10 +151,12 @@ export function createDiaryRoutes(ctx: EngineContext) { // The ring buffer (~500 entries) gets saturated by high-frequency events // (snapshot.skipped, account.health), evicting older heartbeat entries — // the activity we care about here fires only ~every 30min. - // One disk scan with in-memory type filtering is cheaper than three. + // One disk scan with in-memory type+source filtering is cheaper than three. const allEvents = await ctx.eventLog.read({ afterSeq }) - const eventTypes = new Set(HEARTBEAT_EVENT_TYPES) - const events = allEvents.filter((e) => eventTypes.has(e.type)) + const typeSet = new Set(AGENT_WORK_EVENT_TYPES) + const events = allEvents.filter( + (e) => typeSet.has(e.type) && isHeartbeatAgentWorkEvent(e), + ) const cycles = buildDiaryCycles(events).slice(-limit) // Read heartbeat session entries. diff --git a/src/webui/routes/events.ts b/src/webui/routes/events.ts index 9f4a1c41..5e113d82 100644 --- a/src/webui/routes/events.ts +++ b/src/webui/routes/events.ts @@ -10,7 +10,7 @@ interface EventsDeps { ctx: EngineContext /** Producer for webhook-ingested events. Narrowed to the set of external * types; extend its declaration in WebPlugin when adding new external types. */ - ingestProducer: ProducerHandle + ingestProducer: ProducerHandle } /** Event log routes: GET /, GET /recent, GET /stream (SSE), POST /ingest, GET /auth-status */ @@ -78,7 +78,19 @@ export function createEventsRoutes({ ctx, ingestProducer }: EventsDeps) { const { type, payload } = body as { type: string; payload: unknown } - if (!isExternalEventType(type)) { + // Legacy alias: `task.requested` is the pre-AgentWork name for what + // is now `agent.work.requested { source: 'task' }`. Accept it on + // the wire and translate to the canonical form so existing external + // integrations don't break. Memory: "Don't delete our own exports". + let canonicalType = type + let canonicalPayload = payload + if (type === 'task.requested') { + canonicalType = 'agent.work.requested' + const p = (payload ?? {}) as { prompt?: string } + canonicalPayload = { source: 'task', prompt: p.prompt ?? '' } + } + + if (!isExternalEventType(canonicalType)) { return c.json( { error: `Event type '${type}' is not in the external allowlist` }, 403, @@ -86,7 +98,7 @@ export function createEventsRoutes({ ctx, ingestProducer }: EventsDeps) { } try { - validateEventPayload(type, payload) + validateEventPayload(canonicalType, canonicalPayload) } catch (err) { const msg = err instanceof Error ? err.message : String(err) return c.json({ error: msg }, 400) @@ -97,7 +109,7 @@ export function createEventsRoutes({ ctx, ingestProducer }: EventsDeps) { t: string, p: unknown, ) => Promise<{ seq: number; ts: number; type: string; payload: unknown }> - )(type, payload) + )(canonicalType, canonicalPayload) return c.json(entry, 201) }) From f342ce4b18d9712b57fbc37dce4904441855e562 Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 11 May 2026 13:12:11 +0800 Subject: [PATCH 08/12] feat(core): extract parseDuration to src/core/duration.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cron-engine has been the sole owner of `parseDuration` (the "30m" / "1h" / "5m30s" parser). The new Pump primitive (next commit) needs the same parser, and `src/core` → `src/task` would be a wrong-direction import. Move it to `src/core/duration.ts` with a small dedicated spec. cron-engine re-exports it from the original path for back-compat with any internal importers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/duration.spec.ts | 44 +++++++++++++++++++++++++++++++++++++++ src/core/duration.ts | 22 ++++++++++++++++++++ src/task/cron/engine.ts | 14 +++---------- 3 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 src/core/duration.spec.ts create mode 100644 src/core/duration.ts diff --git a/src/core/duration.spec.ts b/src/core/duration.spec.ts new file mode 100644 index 00000000..5a427698 --- /dev/null +++ b/src/core/duration.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { parseDuration } from './duration.js' + +describe('parseDuration', () => { + it('parses minutes-only', () => { + expect(parseDuration('30m')).toBe(30 * 60 * 1000) + }) + + it('parses hours-only', () => { + expect(parseDuration('1h')).toBe(60 * 60 * 1000) + }) + + it('parses seconds-only', () => { + expect(parseDuration('45s')).toBe(45 * 1000) + }) + + it('parses combined h+m+s', () => { + expect(parseDuration('2h15m30s')).toBe((2 * 3600 + 15 * 60 + 30) * 1000) + }) + + it('parses h+m subset', () => { + expect(parseDuration('1h30m')).toBe((3600 + 30 * 60) * 1000) + }) + + it('parses m+s subset', () => { + expect(parseDuration('5m30s')).toBe((5 * 60 + 30) * 1000) + }) + + it('trims surrounding whitespace', () => { + expect(parseDuration(' 30m ')).toBe(30 * 60 * 1000) + }) + + it('returns null for unparseable format', () => { + expect(parseDuration('30 minutes')).toBeNull() + expect(parseDuration('1d')).toBeNull() + expect(parseDuration('')).toBeNull() + expect(parseDuration('abc')).toBeNull() + }) + + it('returns null for zero-total duration', () => { + expect(parseDuration('0m')).toBeNull() + expect(parseDuration('0h0m0s')).toBeNull() + }) +}) diff --git a/src/core/duration.ts b/src/core/duration.ts new file mode 100644 index 00000000..c721fb56 --- /dev/null +++ b/src/core/duration.ts @@ -0,0 +1,22 @@ +/** + * Duration parsing shared between the cron-engine (user-defined cron jobs) + * and the Pump primitive (internal scheduled-task timers like heartbeat + * and snapshot). + * + * Format: `hms` with any subset of parts present in that order + * (e.g. "30m", "1h", "5m30s", "2h15m"). Trims whitespace. Zero-total + * durations return null — a zero-interval pump or cron job is almost + * certainly a config bug, so we surface it as "unparseable" rather than + * fire-forever-loop. + */ + +export function parseDuration(s: string): number | null { + const re = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/ + const m = re.exec(s.trim()) + if (!m) return null + const h = Number(m[1] ?? 0) + const min = Number(m[2] ?? 0) + const sec = Number(m[3] ?? 0) + if (h === 0 && min === 0 && sec === 0) return null + return (h * 3600 + min * 60 + sec) * 1000 +} diff --git a/src/task/cron/engine.ts b/src/task/cron/engine.ts index 0cbd05e0..f745ca55 100644 --- a/src/task/cron/engine.ts +++ b/src/task/cron/engine.ts @@ -17,6 +17,9 @@ import { dirname } from 'node:path' import { randomUUID } from 'node:crypto' import type { ListenerRegistry } from '../../core/listener-registry.js' import type { ProducerHandle } from '../../core/producer.js' +import { parseDuration } from '../../core/duration.js' + +export { parseDuration } // ==================== Types ==================== @@ -311,17 +314,6 @@ export function computeNextRun(schedule: CronSchedule, afterMs: number): number } } -export function parseDuration(s: string): number | null { - const re = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/ - const m = re.exec(s.trim()) - if (!m) return null - const h = Number(m[1] ?? 0) - const min = Number(m[2] ?? 0) - const sec = Number(m[3] ?? 0) - if (h === 0 && min === 0 && sec === 0) return null - return (h * 3600 + min * 60 + sec) * 1000 -} - /** * Minimal cron expression parser (minute hour dom month dow). * Returns the next fire time after `afterMs`, or null if unparseable. From 131243f145a39c6b3a0907d98d9b4d0b1f06b5c4 Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 11 May 2026 13:12:24 +0800 Subject: [PATCH 09/12] =?UTF-8?q?feat(core):=20Pump=20primitive=20?= =?UTF-8?q?=E2=80=94=20interval-scheduled=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small timer primitive for "fire onTick every N minutes" services. Used by heartbeat and snapshot to free them from registering internal cron jobs and subscribing to cron.fire just to drive their own private schedule. Cron-engine is reserved for user-defined cron jobs (Automation > Cron UI). API: const pump = createPump({ name: 'heartbeat', every: '30m', enabled: true, onTick: async () => { ... }, }) pump.start() // arms the timer pump.stop() // terminal — clears timer, marks stopped pump.setEnabled(true | false) // toggle; arms or cancels pending pump.isEnabled() // current state await pump.runNow() // manual fire (tests / UI run-now buttons) Tick algorithm: 1. If stopped → return 2. If serial && processing → drop (log warning, don't queue) 3. processing = true; try onTick() 4. On success: consecutiveErrors = 0 5. On throw: log, consecutiveErrors++ 6. Finally: processing = false; if !stopped && enabled → arm next timeout (delay = errorBackoffMs[errors-1] || parsed every duration) Error backoff defaults match cron-engine's existing schedule: [30s, 60s, 5m, 15m, 1h], clamped to the last entry. Test coverage (`pump.spec.ts`, 23 tests): - Construction: invalid duration throws, zero throws, common formats parse - Lifecycle: start arms; recurring fires; stop clears timer; stop during in-flight tick lets it complete without re-arm; start is idempotent - setEnabled: disabled state doesn't fire; re-enable arms; disable cancels pending; no-op after stop; same-value is no-op - runNow: immediate invocation; works without start; no-op after stop; respects serial guard (awaits in-flight) - Error backoff: consecutive errors trigger increasing backoff; success resets; clamps to last entry on extreme repeats; pump survives onTick errors - Serial guard: in-flight ticks drop concurrent fires No consumers wired yet — followup commits migrate snapshot then heartbeat. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/pump.spec.ts | 337 ++++++++++++++++++++++++++++++++++++++++++ src/core/pump.ts | 190 ++++++++++++++++++++++++ 2 files changed, 527 insertions(+) create mode 100644 src/core/pump.spec.ts create mode 100644 src/core/pump.ts diff --git a/src/core/pump.spec.ts b/src/core/pump.spec.ts new file mode 100644 index 00000000..5bea61d0 --- /dev/null +++ b/src/core/pump.spec.ts @@ -0,0 +1,337 @@ +/** + * Pump — comprehensive coverage of the interval-scheduled callback + * primitive. Uses vitest's fake timers (vi.useFakeTimers) so we can + * advance the clock without sleeping. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { createPump } from './pump.js' + +describe('Pump', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // ==================== Construction ==================== + + describe('construction', () => { + it('throws on invalid duration', () => { + expect(() => createPump({ + name: 'bad', + every: 'not-a-duration', + onTick: async () => {}, + })).toThrow(/invalid duration/) + }) + + it('throws on zero duration', () => { + expect(() => createPump({ + name: 'bad', + every: '0m', + onTick: async () => {}, + })).toThrow(/invalid duration/) + }) + + it('parses common formats successfully', () => { + expect(() => createPump({ name: 'a', every: '30m', onTick: async () => {} })).not.toThrow() + expect(() => createPump({ name: 'a', every: '1h', onTick: async () => {} })).not.toThrow() + expect(() => createPump({ name: 'a', every: '5m30s', onTick: async () => {} })).not.toThrow() + }) + }) + + // ==================== Lifecycle ==================== + + describe('lifecycle', () => { + it('does not fire before start()', async () => { + const onTick = vi.fn(async () => {}) + createPump({ name: 'p', every: '1m', onTick }) + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + expect(onTick).not.toHaveBeenCalled() + }) + + it('start() arms the timer and onTick fires after the interval', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('re-arms after a tick completes (recurring)', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).toHaveBeenCalledTimes(3) + }) + + it('stop() clears the pending timer; no further fires', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + pump.stop() + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + expect(onTick).not.toHaveBeenCalled() + }) + + it('stop() during in-flight tick: tick completes, no re-arm', async () => { + let resolveTick: () => void = () => {} + const onTick = vi.fn(() => new Promise((resolve) => { resolveTick = resolve })) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + + await vi.advanceTimersByTimeAsync(60_000) // schedule first tick + + // Tick is now in flight (onTick promise pending). + pump.stop() + resolveTick() // let the tick complete + await vi.runAllTimersAsync() + + expect(onTick).toHaveBeenCalledTimes(1) + // No further fires + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('start() is idempotent — calling twice does not double-arm', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + pump.start() + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).toHaveBeenCalledTimes(1) // not 2 + }) + }) + + // ==================== Enable / disable ==================== + + describe('setEnabled', () => { + it('disabled state — no fires even after start', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick, enabled: false }) + pump.start() + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + expect(onTick).not.toHaveBeenCalled() + expect(pump.isEnabled()).toBe(false) + }) + + it('setEnabled(true) on a disabled pump re-arms', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick, enabled: false }) + pump.start() + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).not.toHaveBeenCalled() + + pump.setEnabled(true) + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('setEnabled(false) cancels the pending timer', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + pump.setEnabled(false) + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).not.toHaveBeenCalled() + }) + + it('setEnabled is a no-op after stop()', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + pump.stop() + pump.setEnabled(true) + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).not.toHaveBeenCalled() + }) + + it('setEnabled to same value is a no-op', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + pump.setEnabled(true) // already true + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).toHaveBeenCalledTimes(1) // not double-armed + }) + }) + + // ==================== runNow ==================== + + describe('runNow', () => { + it('invokes onTick immediately', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + await pump.runNow() + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('runNow works without start() ever being called', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + await pump.runNow() + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('runNow is a no-op after stop()', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.stop() + await pump.runNow() + expect(onTick).not.toHaveBeenCalled() + }) + + it('runNow respects serial guard — awaits in-flight tick', async () => { + let resolveTick: () => void = () => {} + const onTick = vi.fn(() => new Promise((resolve) => { resolveTick = resolve })) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + await vi.advanceTimersByTimeAsync(60_000) // first tick in flight + + // runNow shouldn't fire a second concurrent tick + const runNowPromise = pump.runNow() + resolveTick() + await runNowPromise + + // Only one onTick invocation (the scheduled one) + expect(onTick).toHaveBeenCalledTimes(1) + }) + }) + + // ==================== Error backoff ==================== + + describe('error backoff', () => { + it('consecutive errors trigger increasing backoff', async () => { + let calls = 0 + const onTick = vi.fn(async () => { + calls++ + throw new Error('always fails') + }) + const pump = createPump({ + name: 'p', + every: '1m', + onTick, + errorBackoffMs: [30_000, 60_000, 300_000], + logger: { log: () => {}, warn: () => {}, error: () => {} }, + }) + pump.start() + + // first scheduled fire at 60s → throws + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + + // next fire is backoff[0] = 30s later, not 60s + await vi.advanceTimersByTimeAsync(30_000) + expect(calls).toBe(2) + + // backoff[1] = 60s + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(3) + + // backoff[2] = 300s + await vi.advanceTimersByTimeAsync(300_000) + expect(calls).toBe(4) + + // clamped to last entry on more failures + await vi.advanceTimersByTimeAsync(300_000) + expect(calls).toBe(5) + }) + + it('successful tick resets consecutiveErrors', async () => { + let calls = 0 + let throwOnNext = true + const onTick = vi.fn(async () => { + calls++ + if (throwOnNext) throw new Error('fail') + }) + const pump = createPump({ + name: 'p', + every: '1m', + onTick, + errorBackoffMs: [30_000, 60_000], + logger: { log: () => {}, warn: () => {}, error: () => {} }, + }) + pump.start() + + await vi.advanceTimersByTimeAsync(60_000) // fail #1 + expect(calls).toBe(1) + + throwOnNext = false + await vi.advanceTimersByTimeAsync(30_000) // backoff[0]; succeeds + expect(calls).toBe(2) + + // Now next interval is back to normal 60s (errors reset) + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(3) + }) + + it('onTick error does not kill the pump', async () => { + let calls = 0 + const onTick = vi.fn(async () => { + calls++ + if (calls === 1) throw new Error('one bad apple') + }) + const pump = createPump({ + name: 'p', + every: '1m', + onTick, + errorBackoffMs: [30_000], + logger: { log: () => {}, warn: () => {}, error: () => {} }, + }) + pump.start() + + await vi.advanceTimersByTimeAsync(60_000) // fail + await vi.advanceTimersByTimeAsync(30_000) // succeed (backoff) + await vi.advanceTimersByTimeAsync(60_000) // back to normal + expect(calls).toBe(3) + }) + }) + + // ==================== Serial guard ==================== + + describe('serial guard', () => { + it('drops a fire when previous tick still in flight (serial=true default)', async () => { + let resolveTick: () => void = () => {} + let calls = 0 + const onTick = vi.fn(() => { + calls++ + return new Promise((resolve) => { resolveTick = resolve }) + }) + const pump = createPump({ + name: 'p', + every: '1m', + onTick, + logger: { log: () => {}, warn: () => {}, error: () => {} }, + }) + pump.start() + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + // 60s later — onTick still pending. The next timer DOES fire but + // is dropped at the serial check; no extra onTick call. + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + // Resolve the in-flight tick; pump re-arms + resolveTick() + await vi.runAllTimersAsync() + // After re-arm, the next fire happens at the configured interval + // (advance through it) + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBeGreaterThanOrEqual(2) + }) + }) + + // ==================== Properties ==================== + + describe('properties', () => { + it('exposes name and every', () => { + const pump = createPump({ name: 'foo', every: '30m', onTick: async () => {} }) + expect(pump.name).toBe('foo') + expect(pump.every).toBe('30m') + }) + }) +}) diff --git a/src/core/pump.ts b/src/core/pump.ts new file mode 100644 index 00000000..d8829675 --- /dev/null +++ b/src/core/pump.ts @@ -0,0 +1,190 @@ +/** + * Pump — interval-scheduled callback primitive. + * + * The replacement for "register an internal cron job + listen for cron.fire + * + filter by jobName" for system services like heartbeat and snapshot that + * are just "fire onTick every N minutes". Owns a setTimeout chain (not + * setInterval, so the next fire is always spaced after onTick completes). + * + * Cron-engine remains for **user-defined** cron jobs (the Automation > Cron + * UI). Pump is the right primitive when: + * - The schedule is `every ` (no cron expressions) + * - The callback is private to one module (no need for event-log fan-out + * to other listeners) + * - Lifecycle (enable/disable, runNow, error backoff) belongs to the + * owning module + * + * Behaviour: + * - `serial: true` (default): a fire while a previous tick is in flight + * is dropped (logged), not queued + * - On onTick throw: log, increment consecutiveErrors, next fire is + * delayed by `errorBackoffMs[consecutiveErrors-1]` (capped at the + * last entry); reset on a successful tick + * - `stop()` is terminal — clears the pending timer, marks stopped; + * subsequent calls to `setEnabled` / `runNow` are no-ops + */ + +import { parseDuration } from './duration.js' + +const DEFAULT_ERROR_BACKOFF_MS: readonly number[] = [ + 30_000, // 30s + 60_000, // 1m + 300_000, // 5m + 900_000, // 15m + 3_600_000, // 1h +] + +export interface PumpOpts { + /** Identifier — used in log lines so multiple pumps stay distinguishable. */ + name: string + /** Tick interval, parsed via shared parseDuration ("30m", "1h", "5m30s"). */ + every: string + /** Initial enabled state. Default true. */ + enabled?: boolean + /** Called on each tick. May throw — pump catches and backoffs. */ + onTick: () => Promise + /** When true (default), a fire that arrives while the previous tick is + * still in flight is dropped. When false, ticks overlap freely. */ + serial?: boolean + /** Backoff schedule for consecutive errors. Index is + * `consecutiveErrors - 1`, clamped to the last entry. */ + errorBackoffMs?: readonly number[] + /** Inject clock for tests. */ + now?: () => number + /** Inject scheduler — vitest fake timers also work via the global. */ + setTimeoutFn?: typeof setTimeout + clearTimeoutFn?: typeof clearTimeout + /** Inject logger. */ + logger?: Pick +} + +export interface Pump { + /** Arm the timer if enabled. Idempotent — calling twice is a no-op. */ + start(): void + /** Terminal stop. Clears the pending timer; sets stopped flag. */ + stop(): void + /** Toggle enabled. `true` re-arms if not already armed; `false` clears + * the pending timer (in-flight tick continues but doesn't reschedule). */ + setEnabled(enabled: boolean): void + isEnabled(): boolean + /** Manually invoke onTick once now, outside the schedule. Returns when + * the tick completes (success or caught error). Respects serial guard: + * if a tick is already in flight, awaits the in-flight one. */ + runNow(): Promise + readonly every: string + readonly name: string +} + +export function createPump(opts: PumpOpts): Pump { + const name = opts.name + const every = opts.every + const onTick = opts.onTick + const serial = opts.serial ?? true + const backoff = opts.errorBackoffMs ?? DEFAULT_ERROR_BACKOFF_MS + const setTimeoutFn = opts.setTimeoutFn ?? setTimeout + const clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout + const logger = opts.logger ?? console + + const intervalMs = parseDuration(every) + if (intervalMs === null) { + throw new Error(`pump[${name}]: invalid duration '${every}'`) + } + + let enabled = opts.enabled ?? true + let stopped = false + let started = false + let processing = false + let consecutiveErrors = 0 + let timer: ReturnType | null = null + /** Promise of the in-flight tick (if any) — runNow can await this. */ + let inFlight: Promise | null = null + + function nextDelayMs(): number { + if (consecutiveErrors <= 0) return intervalMs! + const idx = Math.min(consecutiveErrors - 1, backoff.length - 1) + return backoff[idx] + } + + function armNext(): void { + if (stopped || !enabled) return + if (timer !== null) return // already armed + timer = setTimeoutFn(() => { + timer = null + void tick() + }, nextDelayMs()) + } + + function clearTimer(): void { + if (timer !== null) { + clearTimeoutFn(timer) + timer = null + } + } + + async function tick(): Promise { + if (stopped) return + if (serial && processing) { + // Drop this fire — the previous tick hasn't finished. Don't arm + // another timer; whichever tick wins eventually re-arms. + logger.warn(`pump[${name}]: tick dropped (still processing previous)`) + return + } + processing = true + const runOne = async (): Promise => { + try { + await onTick() + consecutiveErrors = 0 + } catch (err) { + consecutiveErrors++ + logger.error( + `pump[${name}]: onTick error (consecutive=${consecutiveErrors}):`, + err, + ) + } finally { + processing = false + if (inFlight === thisRun) inFlight = null + armNext() + } + } + const thisRun = runOne() + inFlight = thisRun + await thisRun + } + + return { + name, + every, + start() { + if (started || stopped) return + started = true + armNext() + }, + stop() { + stopped = true + clearTimer() + }, + setEnabled(next: boolean) { + if (stopped) return + if (enabled === next) return + enabled = next + if (next) { + armNext() + } else { + clearTimer() + } + }, + isEnabled() { + return enabled + }, + async runNow() { + if (stopped) return + // If a tick is already in flight (serial-protected), don't double-fire; + // just wait for it. Otherwise invoke a fresh tick. + if (processing && inFlight) { + await inFlight + return + } + await tick() + }, + } +} From 2fa7e4c65e964d348d177fd6b82efc2988dd22c3 Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 11 May 2026 13:12:34 +0800 Subject: [PATCH 10/12] refactor(snapshot): scheduler driven by Pump instead of cron-engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot is a state-persistence service — capturing trading state to disk every N minutes — not an AI-work event consumer. It was registering itself as a `__snapshot__` cron job and subscribing to `cron.fire` filtered by jobName just to drive its own schedule. That entangled it with the user-cron event flow for no reason. Now it owns a private Pump and calls `snapshotService.takeAllSnapshots` directly. Zero event-log involvement at the timer layer. API shrinks too — `createSnapshotScheduler({ snapshotService, config })` (no more cronEngine + registry deps). The scheduler exports `runNow()` for manual triggering (tests + future UI). UTA post-push / post-reject hooks are unaffected — those call `snapshotService.takeSnapshot(id, trigger)` directly, never went through the scheduler. Tests rewritten (`snapshot.spec.ts`, scheduler block): 6 tests covering runNow invocation, idempotent start, disabled-config behaviour, serial guard, stop semantics, takeAllSnapshots error resilience. Drops the cron-engine fixture entirely. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/domain/trading/snapshot/scheduler.ts | 90 ++++--------- src/domain/trading/snapshot/snapshot.spec.ts | 131 ++++++------------- 2 files changed, 69 insertions(+), 152 deletions(-) diff --git a/src/domain/trading/snapshot/scheduler.ts b/src/domain/trading/snapshot/scheduler.ts index 3bf6c75b..eb9c39e0 100644 --- a/src/domain/trading/snapshot/scheduler.ts +++ b/src/domain/trading/snapshot/scheduler.ts @@ -1,20 +1,20 @@ /** - * Snapshot scheduler — periodic snapshots via cron engine. + * Snapshot scheduler — periodic snapshots via Pump. * - * Registers a cron job (`__snapshot__`) and registers a `cron.fire` listener - * with the ListenerRegistry. When fired, captures snapshots for all accounts. + * Pre-AgentWork refactor, this registered an internal `__snapshot__` + * cron job and subscribed to `cron.fire` filtered by jobName. That was + * conceptual debt — snapshot is a state-persistence service, not an + * AI-work event consumer. It now drives a private Pump (interval-based + * timer) and calls into `snapshotService.takeAllSnapshots('scheduled')` + * directly. No event-log involvement at the timer layer. * - * Follows the same pattern as the heartbeat system. + * UTA post-push / post-reject hooks still call + * `snapshotService.takeSnapshot(id, trigger)` directly — those paths + * are unchanged. */ -import type { EventLogEntry } from '../../../core/event-log.js' -import type { CronFirePayload } from '../../../core/agent-event.js' -import type { CronEngine } from '../../../task/cron/engine.js' import type { SnapshotService } from './service.js' -import type { Listener } from '../../../core/listener.js' -import type { ListenerRegistry } from '../../../core/listener-registry.js' - -const SNAPSHOT_JOB_NAME = '__snapshot__' +import { createPump, type Pump } from '../../../core/pump.js' export interface SnapshotConfig { enabled: boolean @@ -24,72 +24,34 @@ export interface SnapshotConfig { export interface SnapshotScheduler { start(): Promise stop(): void - readonly listener: Listener<'cron.fire'> + /** Manually trigger a scheduled snapshot run (e.g. for tests / UI). */ + runNow(): Promise } export function createSnapshotScheduler(deps: { snapshotService: SnapshotService - cronEngine: CronEngine - registry: ListenerRegistry config: SnapshotConfig }): SnapshotScheduler { - const { snapshotService, cronEngine, registry, config } = deps - - let processing = false - let registered = false - - async function handleFire(entry: EventLogEntry): Promise { - const payload = entry.payload - if (payload.jobName !== SNAPSHOT_JOB_NAME) return - if (processing) return - - processing = true - try { - await snapshotService.takeAllSnapshots('scheduled') - } catch (err) { - console.warn('snapshot-scheduler: error:', err instanceof Error ? err.message : err) - } finally { - processing = false - } - } + const { snapshotService, config } = deps - const listener: Listener<'cron.fire'> = { + const pump: Pump = createPump({ name: 'snapshot', - subscribes: 'cron.fire', - handle: handleFire, - } + every: config.every, + enabled: config.enabled, + onTick: async () => { + await snapshotService.takeAllSnapshots('scheduled') + }, + }) return { - listener, async start() { - // Find or create the cron job - const existing = cronEngine.list().find(j => j.name === SNAPSHOT_JOB_NAME) - if (existing) { - await cronEngine.update(existing.id, { - schedule: { kind: 'every', every: config.every }, - enabled: config.enabled, - }) - } else { - await cronEngine.add({ - name: SNAPSHOT_JOB_NAME, - schedule: { kind: 'every', every: config.every }, - payload: '', - enabled: config.enabled, - }) - } - - // Register listener exactly once - if (!registered) { - registry.register(listener) - registered = true - } + pump.start() }, - stop() { - if (registered) { - registry.unregister(listener.name) - registered = false - } + pump.stop() + }, + async runNow() { + await pump.runNow() }, } } diff --git a/src/domain/trading/snapshot/snapshot.spec.ts b/src/domain/trading/snapshot/snapshot.spec.ts index cc7ebc85..b86ebaff 100644 --- a/src/domain/trading/snapshot/snapshot.spec.ts +++ b/src/domain/trading/snapshot/snapshot.spec.ts @@ -10,8 +10,6 @@ import type { UnifiedTradingAccountOptions } from '../UnifiedTradingAccount.js' import { MockBroker, makeContract, makePosition, makeOpenOrder } from '../brokers/mock/index.js' import { UTAManager } from '../uta-manager.js' import { createEventLog, type EventLog } from '../../../core/event-log.js' -import { createListenerRegistry, type ListenerRegistry } from '../../../core/listener-registry.js' -import { createCronEngine, type CronEngine } from '../../../task/cron/engine.js' import { buildSnapshot } from './builder.js' import { createSnapshotStore, type SnapshotStore } from './store.js' import { createSnapshotService, type SnapshotService } from './service.js' @@ -448,21 +446,10 @@ describe('Snapshot Service', () => { // ==================== Scheduler Tests ==================== describe('Snapshot Scheduler', () => { - let eventLog: EventLog - let listenerRegistry: ListenerRegistry - let cronEngine: CronEngine let scheduler: SnapshotScheduler let mockService: SnapshotService - beforeEach(async () => { - const logPath = tempPath('jsonl') - const storePath = tempPath('json') - eventLog = await createEventLog({ logPath }) - listenerRegistry = createListenerRegistry(eventLog) - await listenerRegistry.start() - cronEngine = createCronEngine({ registry: listenerRegistry, storePath }) - await cronEngine.start() - + beforeEach(() => { mockService = { takeSnapshot: vi.fn(async () => null), takeAllSnapshots: vi.fn(async () => {}), @@ -472,108 +459,76 @@ describe('Snapshot Scheduler', () => { scheduler = createSnapshotScheduler({ snapshotService: mockService, - cronEngine, - registry: listenerRegistry, config: { enabled: true, every: '15m' }, }) }) - afterEach(async () => { + afterEach(() => { scheduler?.stop() - cronEngine.stop() - await listenerRegistry.stop() - await eventLog._resetForTest() }) - // #26 - it('registers __snapshot__ cron job on start', async () => { + // #26: runNow invokes takeAllSnapshots + it('runNow invokes takeAllSnapshots("scheduled")', async () => { await scheduler.start() - - const jobs = cronEngine.list() - const snapshotJob = jobs.find(j => j.name === '__snapshot__') - expect(snapshotJob).toBeDefined() - expect(snapshotJob!.enabled).toBe(true) + await scheduler.runNow() + expect(mockService.takeAllSnapshots).toHaveBeenCalledWith('scheduled') }) - // #27 - it('reuses existing job on repeated start (idempotent)', async () => { + // #27: idempotent start + it('start is idempotent — multiple calls do not double-fire', async () => { await scheduler.start() - const jobsBefore = cronEngine.list().filter(j => j.name === '__snapshot__') - - await scheduler.start() - const jobsAfter = cronEngine.list().filter(j => j.name === '__snapshot__') - - expect(jobsBefore).toHaveLength(1) - expect(jobsAfter).toHaveLength(1) - expect(jobsBefore[0].id).toBe(jobsAfter[0].id) - }) - - // #28 - it('fires takeAllSnapshots on cron.fire event', async () => { await scheduler.start() - - // Trigger the cron job manually - const job = cronEngine.list().find(j => j.name === '__snapshot__')! - await cronEngine.runNow(job.id) - - // Give the async handler time to complete - await new Promise(r => setTimeout(r, 50)) - - expect(mockService.takeAllSnapshots).toHaveBeenCalledWith('scheduled') + await scheduler.runNow() + expect(mockService.takeAllSnapshots).toHaveBeenCalledTimes(1) }) - // #29 - it('ignores cron.fire for other jobs', async () => { - await scheduler.start() - - // Create a different job and fire it - const otherId = await cronEngine.add({ - name: 'other-job', - schedule: { kind: 'every', every: '1h' }, - payload: '', + // #28: disabled scheduler doesn't fire on its own (but runNow still works) + it('disabled config: pump does not auto-fire, runNow still works', async () => { + scheduler = createSnapshotScheduler({ + snapshotService: mockService, + config: { enabled: false, every: '15m' }, }) - await cronEngine.runNow(otherId) - - await new Promise(r => setTimeout(r, 50)) - - expect(mockService.takeAllSnapshots).not.toHaveBeenCalled() + await scheduler.start() + // No auto-fire — runNow explicitly invokes + await scheduler.runNow() + expect(mockService.takeAllSnapshots).toHaveBeenCalledTimes(1) }) - // #30 + // #29: serial guard prevents concurrent fires it('processing lock prevents concurrent fires', async () => { - // Make takeAllSnapshots slow - let resolveFirst: () => void + let resolveFirst: () => void = () => {} const firstCall = new Promise(r => { resolveFirst = r }) - ;(mockService.takeAllSnapshots as any).mockImplementationOnce(async () => { - await firstCall - }) + ;(mockService.takeAllSnapshots as ReturnType) + .mockImplementationOnce(async () => { await firstCall }) await scheduler.start() - const job = cronEngine.list().find(j => j.name === '__snapshot__')! - - // Fire twice quickly - await cronEngine.runNow(job.id) - await new Promise(r => setTimeout(r, 10)) - await cronEngine.runNow(job.id) - await new Promise(r => setTimeout(r, 10)) - - // Second fire should be skipped (processing=true) - resolveFirst!() - await new Promise(r => setTimeout(r, 50)) - + // Kick off two concurrent runNow calls; the second should await the first. + const r1 = scheduler.runNow() + const r2 = scheduler.runNow() + resolveFirst() + await Promise.all([r1, r2]) + // Only one invocation — second was coalesced into the in-flight one expect(mockService.takeAllSnapshots).toHaveBeenCalledTimes(1) }) - // #31 - it('stop() unsubscribes from events', async () => { + // #30: stop terminates the pump + it('stop() prevents future runNow from firing', async () => { await scheduler.start() scheduler.stop() + await scheduler.runNow() + expect(mockService.takeAllSnapshots).not.toHaveBeenCalled() + }) - const job = cronEngine.list().find(j => j.name === '__snapshot__')! - await cronEngine.runNow(job.id) - await new Promise(r => setTimeout(r, 50)) + // #31: snapshot service errors are swallowed at the pump level (not fatal) + it('takeAllSnapshots error does not crash the scheduler', async () => { + ;(mockService.takeAllSnapshots as ReturnType) + .mockRejectedValueOnce(new Error('takeAll fail')) - expect(mockService.takeAllSnapshots).not.toHaveBeenCalled() + await scheduler.start() + await scheduler.runNow() // doesn't throw + // pump still functional + await scheduler.runNow() + expect(mockService.takeAllSnapshots).toHaveBeenCalledTimes(2) }) }) From 63116611a0d247294c4d2149ae98b0f8e5ca5b82 Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 11 May 2026 13:12:50 +0800 Subject: [PATCH 11/12] refactor(heartbeat): migrate to Pump; remove isInternalJob filter; orphan cron cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heartbeat is the second and final internal scheduled-task service to move off cron-engine. Now Pump-driven, with the same shape as snapshot. Heartbeat changes: - Drops CronEngine dependency from HeartbeatOpts - Stops being a Listener (no more `subscribes: 'cron.fire'`, no more `__heartbeat__` jobName filter) - Owns a private Pump driving the schedule - Owns a ProducerHandle that emits agent.work.{requested,skip} on each tick (visible to the topology graph as a clean producer on agent-work events) - Active-hours pre-filter stays — but inline in onTick, with the skip event emitted via the producer - Adds `runNow()` exposed on the Heartbeat API for tests and future "run heartbeat now" UI - HEARTBEAT_JOB_NAME export removed (no longer a cron-engine job) - HeartbeatDedup + isWithinActiveHours helpers unchanged cron-router cleanup (src/task/cron/listener.ts): - Removes the `isInternalJob` filter and its test. Pre-Pump this guarded against double-handling __heartbeat__ / __snapshot__ fires; post-Pump those jobs don't exist in cron-engine, so the filter is dead code. One-time disk migration (src/main.ts): - After cron-engine starts, scan its job list and remove any `__*__`-named entries (orphan disk state from data/cron/jobs.json that previous versions left behind). Logs each removal once. Idempotent — no-op on subsequent startups. Tests rewritten (`heartbeat.spec.ts`, 27 tests): - All triggering now via `heartbeat.runNow()` instead of `cronEngine.runNow(jobId)` - Anti-regression test: heartbeat does NOT subscribe to legacy `cron.fire { jobName: '__heartbeat__' }` events - Active-hours, dedup, error handling, setEnabled all preserved - runNow ignores the enabled flag (manual fires always work, even when scheduled fires are disabled) Full suite: 1662 / 1662 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 23 +++- src/task/cron/listener.spec.ts | 17 +-- src/task/cron/listener.ts | 18 +-- src/task/heartbeat/heartbeat.spec.ts | 172 ++++++++++------------- src/task/heartbeat/heartbeat.ts | 198 ++++++++++++--------------- src/task/heartbeat/index.ts | 2 +- 6 files changed, 185 insertions(+), 245 deletions(-) diff --git a/src/main.ts b/src/main.ts index a8df8e5e..038addee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -313,6 +313,21 @@ async function main() { buildErrorMetadata: (req) => ({ prompt: req.prompt }), }) + // ==================== One-time migration: clean orphan internal cron jobs ==================== + // + // Prior to the Pump refactor, heartbeat and snapshot registered their + // schedules as internal cron jobs (__heartbeat__, __snapshot__) inside + // the cron engine. They now own private Pumps. Any pre-existing entries + // in data/cron/jobs.json are orphan disk state — clean them up so + // they don't show in the Automation > Cron UI and don't take up + // memory in cron-engine. + for (const job of cronEngine.list()) { + if (job.name.startsWith('__') && job.name.endsWith('__')) { + console.log(`cron: removing orphan internal job ${job.name} (${job.id})`) + await cronEngine.remove(job.id) + } + } + // ==================== Cron Listener ==================== const cronSession = new SessionStore('cron/default') @@ -320,19 +335,19 @@ async function main() { const cronListener = createCronListener({ agentWorkListener, registry: listenerRegistry, session: cronSession }) await cronListener.start() - // ==================== Snapshot Scheduler ==================== + // ==================== Snapshot Scheduler (Pump-driven) ==================== - const snapshotScheduler = createSnapshotScheduler({ snapshotService, cronEngine, registry: listenerRegistry, config: config.snapshot }) + const snapshotScheduler = createSnapshotScheduler({ snapshotService, config: config.snapshot }) await snapshotScheduler.start() if (config.snapshot.enabled) { console.log(`snapshot: scheduler started (every ${config.snapshot.every})`) } - // ==================== Heartbeat ==================== + // ==================== Heartbeat (Pump-driven) ==================== const heartbeat = createHeartbeat({ config: config.heartbeat, - agentWorkListener, cronEngine, registry: listenerRegistry, + agentWorkListener, registry: listenerRegistry, session: new SessionStore('heartbeat'), }) await heartbeat.start() diff --git a/src/task/cron/listener.spec.ts b/src/task/cron/listener.spec.ts index d242ced6..3c18e164 100644 --- a/src/task/cron/listener.spec.ts +++ b/src/task/cron/listener.spec.ts @@ -119,19 +119,10 @@ describe('cron listener', () => { expect(typeof done.causedBy).toBe('number') }) - it('filters out internal __*__ jobs', async () => { - await eventLog.append('cron.fire', { - jobId: 'hb-id', - jobName: '__heartbeat__', - payload: 'should be ignored by cron-router', - } satisfies CronFirePayload) - - await new Promise((r) => setTimeout(r, 50)) - - // cron-router didn't emit anything (its filter dropped this) - const requested = eventLog.recent({ type: 'agent.work.requested' }) - expect(requested.filter(e => (e.payload as { source: string }).source === 'cron')).toHaveLength(0) - }) + // Note: pre-Pump refactor, cron-router filtered `__*__` jobs to avoid + // double-handling heartbeat / snapshot. After the Pump migration, these + // internal jobs no longer live in the cron engine at all — so the + // filter has been removed. Test deleted alongside the dead code. it('does not react to other event types', async () => { await eventLog.append('message.received' as never, { channel: 'web', to: 'x', prompt: 'p' }) diff --git a/src/task/cron/listener.ts b/src/task/cron/listener.ts index b46ab776..8927bdef 100644 --- a/src/task/cron/listener.ts +++ b/src/task/cron/listener.ts @@ -3,15 +3,16 @@ * canonical `agent.work.requested` events. The agent-work-listener * picks them up and runs the AI dispatch pipeline. * - * Filters out internal `__*__` jobs (heartbeat / snapshot have their - * own handlers). Serial-execution lock preserved from previous design - * so concurrent fires don't overlap. - * - * No notification policy lives here — every successful cron reply is + * Serial-execution lock so concurrent fires don't overlap. No + * notification policy lives here — every successful cron reply is * pushed (the AgentWork default). Cron jobs that want * AI-decides-to-notify semantics can teach their prompt about * `notify_user`; the source config registered here doesn't reference * any output gate, so the default deliver-result.text behaviour wins. + * + * Internal jobs (heartbeat / snapshot) no longer live in the cron + * engine — they each own their own Pump now. So there's no + * `__*__` filter needed in this listener anymore. */ import type { EventLogEntry } from '../../core/event-log.js' @@ -21,10 +22,6 @@ import type { Listener, ListenerContext } from '../../core/listener.js' import type { ListenerRegistry } from '../../core/listener-registry.js' import type { AgentWorkListener, AgentWorkSourceConfig } from '../../core/agent-work-listener.js' -function isInternalJob(name: string): boolean { - return name.startsWith('__') && name.endsWith('__') -} - const CRON_EMITS = ['agent.work.requested'] as const type CronEmits = typeof CRON_EMITS @@ -78,9 +75,6 @@ export function createCronListener(opts: CronListenerOpts): CronListener { ): Promise { const payload = entry.payload - // Internal jobs have dedicated handlers (heartbeat / snapshot) - if (isInternalJob(payload.jobName)) return - if (processing) { console.warn(`cron-listener: skipping job ${payload.jobId} (already processing)`) return diff --git a/src/task/heartbeat/heartbeat.spec.ts b/src/task/heartbeat/heartbeat.spec.ts index 2261ebd7..ad5d3be8 100644 --- a/src/task/heartbeat/heartbeat.spec.ts +++ b/src/task/heartbeat/heartbeat.spec.ts @@ -1,19 +1,19 @@ /** - * Heartbeat tests — exercises the full trigger-source pipeline: + * Heartbeat tests — Pump-driven trigger source. * - * cron.fire (__heartbeat__) - * → heartbeat listener handleFire() - * → active-hours pre-filter (emits agent.work.skip directly if blocked) - * → emits agent.work.requested - * → agent-work-listener (separate test fixture) - * → AgentWorkRunner.run() - * → notify_user-inspection outputGate (with dedup) - * → emits agent.work.done / .skip / .error + * Post-Pump refactor, heartbeat no longer subscribes to cron.fire. + * It owns a private Pump. Tests trigger ticks via `heartbeat.runNow()` + * (which delegates to `pump.runNow()`) rather than `cronEngine.runNow()`. * - * The legacy STATUS regex protocol is gone; notification intent is - * signalled via the notify_user tool. These tests mock the AgentCenter - * result to include or omit the tool call and assert on canonical - * agent.work.* events with source='heartbeat'. + * The full pipeline test path: + * heartbeat.runNow() + * → pump.runNow() → onTick + * → active-hours pre-filter (skip → emit agent.work.skip directly) + * → producer.emit('agent.work.requested') for the canonical event + * → agent-work-listener picks up the request + * → source-config-driven AgentWorkRunner.run() + * → notify_user inspection + dedup gate + * → emit agent.work.{done,skip,error} */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' @@ -22,12 +22,10 @@ import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' import { createEventLog, type EventLog } from '../../core/event-log.js' import { createListenerRegistry, type ListenerRegistry } from '../../core/listener-registry.js' -import { createCronEngine, type CronEngine } from '../cron/engine.js' import { createHeartbeat, isWithinActiveHours, HeartbeatDedup, - HEARTBEAT_JOB_NAME, type Heartbeat, type HeartbeatConfig, } from './heartbeat.js' @@ -37,7 +35,11 @@ import { createMemoryNotificationsStore } from '../../core/notifications-store.j import { AgentWorkRunner } from '../../core/agent-work.js' import { createAgentWorkListener, type AgentWorkListener } from '../../core/agent-work-listener.js' import type { ToolCallSummary } from '../../ai-providers/types.js' -import type { AgentWorkDonePayload, AgentWorkSkipPayload, AgentWorkErrorPayload } from '../../core/agent-event.js' +import type { + AgentWorkDonePayload, + AgentWorkSkipPayload, + AgentWorkErrorPayload, +} from '../../core/agent-event.js' vi.mock('../../core/config.js', () => ({ writeConfigSection: vi.fn(async () => ({})), @@ -93,7 +95,6 @@ function createMockEngine(initial: Partial = {}) { describe('heartbeat', () => { let eventLog: EventLog let listenerRegistry: ListenerRegistry - let cronEngine: CronEngine let heartbeat: Heartbeat let mockEngine: ReturnType let session: SessionStore @@ -102,13 +103,9 @@ describe('heartbeat', () => { let agentWorkListener: AgentWorkListener beforeEach(async () => { - const logPath = tempPath('jsonl') - const storePath = tempPath('json') - eventLog = await createEventLog({ logPath }) + eventLog = await createEventLog({ logPath: tempPath('jsonl') }) listenerRegistry = createListenerRegistry(eventLog) await listenerRegistry.start() - cronEngine = createCronEngine({ registry: listenerRegistry, storePath }) - await cronEngine.start() mockEngine = createMockEngine() session = new SessionStore(`test/heartbeat-${randomUUID()}`) @@ -125,56 +122,28 @@ describe('heartbeat', () => { afterEach(async () => { heartbeat?.stop() agentWorkListener.stop() - cronEngine.stop() await listenerRegistry.stop() await eventLog._resetForTest() }) - // ==================== Start / Idempotency ==================== + // ==================== Lifecycle ==================== - describe('start', () => { - it('registers a cron job on start', async () => { + describe('lifecycle', () => { + it('start() is idempotent', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, - }) - await heartbeat.start() - - const jobs = cronEngine.list() - expect(jobs).toHaveLength(1) - expect(jobs[0].name).toBe(HEARTBEAT_JOB_NAME) - expect(jobs[0].schedule).toEqual({ kind: 'every', every: '30m' }) - }) - - it('idempotent (update existing job, not create duplicate)', async () => { - heartbeat = createHeartbeat({ - config: makeConfig({ every: '30m' }), - agentWorkListener, cronEngine, registry: listenerRegistry, session, - }) - await heartbeat.start() - heartbeat.stop() - - heartbeat = createHeartbeat({ - config: makeConfig({ every: '1h' }), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - - const jobs = cronEngine.list() - expect(jobs).toHaveLength(1) - expect(jobs[0].schedule).toEqual({ kind: 'every', every: '1h' }) + await heartbeat.start() // no error }) - it('registers disabled job when config.enabled is false', async () => { + it('start() respects config.enabled', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - - const jobs = cronEngine.list() - expect(jobs).toHaveLength(1) - expect(jobs[0].enabled).toBe(false) expect(heartbeat.isEnabled()).toBe(false) }) }) @@ -190,10 +159,10 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) @@ -211,10 +180,10 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) @@ -231,10 +200,10 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) @@ -245,16 +214,15 @@ describe('heartbeat', () => { }) it('does NOT regex-parse STATUS-shaped raw text — anti-regression', async () => { - // Legacy protocol response — must NOT trigger any notification. mockEngine.setRawText('STATUS: CHAT_YES\nCONTENT: should NOT be delivered') mockEngine.setNoToolCall() heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) @@ -264,17 +232,20 @@ describe('heartbeat', () => { expect(entries).toHaveLength(0) }) - it('ignores non-heartbeat cron.fire events', async () => { + it('no longer subscribes to cron.fire (decoupled from cron-engine)', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() + // Fire a cron.fire event with the legacy __heartbeat__ jobName. + // Pre-refactor, this would have driven heartbeat. Post-refactor, + // heartbeat is fully decoupled — no AI call should happen. await eventLog.append('cron.fire', { - jobId: 'other-job', - jobName: 'check-eth', - payload: 'Check ETH', + jobId: 'legacy-id', + jobName: '__heartbeat__', + payload: 'should be ignored', }) await new Promise((r) => setTimeout(r, 50)) @@ -292,11 +263,11 @@ describe('heartbeat', () => { config: makeConfig({ activeHours: { start: '09:00', end: '22:00', timezone: 'local' }, }), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, now: () => fakeNow, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) @@ -306,7 +277,7 @@ describe('heartbeat', () => { expect(skip.source).toBe('heartbeat') expect(skip.reason).toBe('outside-active-hours') expect(mockEngine.askWithSession).not.toHaveBeenCalled() - // No agent.work.requested was emitted (pre-emit gate) + // No agent.work.requested emitted (pre-emit gate) const reqs = eventLog.recent({ type: 'agent.work.requested' }) expect(reqs.filter(e => (e.payload as { source: string }).source === 'heartbeat')).toHaveLength(0) }) @@ -323,17 +294,16 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - const jobId = cronEngine.list()[0].id - await cronEngine.runNow(jobId) + await heartbeat.runNow() await vi.waitFor(() => { expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) }) - await cronEngine.runNow(jobId) + await heartbeat.runNow() await vi.waitFor(() => { const skips = eventLog.recent({ type: 'agent.work.skip' }) expect(skips.some(s => (s.payload as AgentWorkSkipPayload).reason === 'duplicate')).toBe(true) @@ -348,17 +318,16 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - const jobId = cronEngine.list()[0].id mockEngine.setNotifyUserCall('First alert') - await cronEngine.runNow(jobId) + await heartbeat.runNow() await vi.waitFor(() => { expect(delivered).toHaveLength(1) }) mockEngine.setNotifyUserCall('Second different alert') - await cronEngine.runNow(jobId) + await heartbeat.runNow() await vi.waitFor(() => { expect(delivered).toHaveLength(2) }) expect(delivered).toEqual(['First alert', 'Second different alert']) @@ -373,10 +342,10 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(1) @@ -394,10 +363,10 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) @@ -410,18 +379,18 @@ describe('heartbeat', () => { }) }) - // ==================== Lifecycle ==================== + // ==================== stop ==================== - describe('lifecycle', () => { - it('stops listening after stop()', async () => { + describe('stop', () => { + it('runNow is a no-op after stop()', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() heartbeat.stop() - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await new Promise((r) => setTimeout(r, 50)) expect(mockEngine.askWithSession).not.toHaveBeenCalled() @@ -434,28 +403,25 @@ describe('heartbeat', () => { it('enables a previously disabled heartbeat', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() expect(heartbeat.isEnabled()).toBe(false) - expect(cronEngine.list()[0].enabled).toBe(false) await heartbeat.setEnabled(true) expect(heartbeat.isEnabled()).toBe(true) - expect(cronEngine.list()[0].enabled).toBe(true) }) it('disables an enabled heartbeat', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: true }), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() expect(heartbeat.isEnabled()).toBe(true) await heartbeat.setEnabled(false) expect(heartbeat.isEnabled()).toBe(false) - expect(cronEngine.list()[0].enabled).toBe(false) }) it('persists config via writeConfigSection', async () => { @@ -463,7 +429,7 @@ describe('heartbeat', () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() await heartbeat.setEnabled(true) @@ -474,22 +440,22 @@ describe('heartbeat', () => { ) }) - it('allows firing after setEnabled(true)', async () => { + it('runNow ignores the enabled flag (always fires for manual trigger)', async () => { const delivered: string[] = [] notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) - mockEngine.setNotifyUserCall('after-enable') + mockEngine.setNotifyUserCall('manual-fire') heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - agentWorkListener, cronEngine, registry: listenerRegistry, session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - await heartbeat.setEnabled(true) - await cronEngine.runNow(cronEngine.list()[0].id) + // Even though enabled=false, manual runNow should still work + await heartbeat.runNow() await vi.waitFor(() => { expect(delivered).toHaveLength(1) }) - expect(delivered[0]).toBe('after-enable') + expect(delivered[0]).toBe('manual-fire') }) }) }) diff --git a/src/task/heartbeat/heartbeat.ts b/src/task/heartbeat/heartbeat.ts index ab2d90cb..ee482347 100644 --- a/src/task/heartbeat/heartbeat.ts +++ b/src/task/heartbeat/heartbeat.ts @@ -1,46 +1,42 @@ /** - * Heartbeat — periodic Alice self-check. + * Heartbeat — periodic Alice self-check, Pump-driven. * - * Today's shape: a trigger source for `agent.work.requested`. Owns - * the `__heartbeat__` cron job lifecycle, the active-hours config, - * and the in-memory dedup window. On each tick: + * Heartbeat is a recurring "ping Alice every N minutes" service. Prior + * to this commit, it piggy-backed on the cron engine: registered an + * internal `__heartbeat__` cron job, subscribed to `cron.fire` filtered + * by jobName, did its work in the handler. That was conceptual debt — + * the cron engine should be reserved for user-defined cron jobs from + * the Automation > Cron UI, and heartbeat's lifecycle (active-hours, + * dedup, hot enable/disable, configured prompt) doesn't belong in a + * "user cron job" shape. * - * 1. Active-hours pre-filter (inputGate equivalent — but enforced - * pre-emit so we don't pollute the event log with skip events - * when the heartbeat shouldn't have fired in the first place). - * Outside hours → emit `agent.work.skip { source: 'heartbeat', - * reason: 'outside-active-hours' }` directly via ctx.emit. + * Now: heartbeat owns a private Pump for its schedule and a + * ProducerHandle for `agent.work.{requested,skip}` emits. The cron + * engine is no longer in its dependency graph. + * + * On each tick: + * 1. Active-hours pre-filter. Outside hours → emit + * `agent.work.skip { source: 'heartbeat', reason: 'outside-active-hours' }` + * and return; AI is never invoked, no token cost. * 2. Otherwise emit `agent.work.requested { source: 'heartbeat', - * prompt }`. The agent-work-listener picks it up and runs. - * 3. The heartbeat-specific outputGate (dedup + notify_user - * inspection) lives in the source config registered with - * agent-work-listener at startup. The runner-side gate sees - * the AI's tool calls and decides deliver vs skip. + * prompt }`. The agent-work-listener routes it through the + * heartbeat source config (notify_user inspection + dedup gate) + * registered at start(). * - * Heartbeat no longer imports AgentWorkRunner directly. State that - * heartbeat owns (HeartbeatDedup, active-hours, cron job lifecycle) - * stays here; AgentWork-pipeline state (session, gates) is registered - * with the listener. + * State heartbeat owns: HeartbeatDedup (24h window), active-hours + * config, the Pump, the ProducerHandle, the source config registered + * with agent-work-listener. AgentWork pipeline state (sessions, + * AI invocation) lives elsewhere. */ -import type { Listener, ListenerContext } from '../../core/listener.js' -import type { ListenerRegistry } from '../../core/listener-registry.js' import { SessionStore } from '../../core/session.js' import { writeConfigSection } from '../../core/config.js' -import type { CronEngine } from '../cron/engine.js' +import type { ListenerRegistry } from '../../core/listener-registry.js' +import type { ProducerHandle } from '../../core/producer.js' +import { createPump, type Pump } from '../../core/pump.js' import type { AgentWorkListener, AgentWorkSourceConfig } from '../../core/agent-work-listener.js' import type { AgentWorkResultProbe } from '../../core/agent-work.js' -const HEARTBEAT_EMITS = [ - 'agent.work.requested', - 'agent.work.skip', -] as const -type HeartbeatEmits = typeof HEARTBEAT_EMITS - -// ==================== Constants ==================== - -export const HEARTBEAT_JOB_NAME = '__heartbeat__' - // ==================== Config ==================== export interface HeartbeatConfig { @@ -79,8 +75,9 @@ export interface HeartbeatOpts { /** Where to register the heartbeat source config so the agent-work * pipeline knows how to handle heartbeat-sourced requests. */ agentWorkListener: AgentWorkListener - cronEngine: CronEngine - /** Listener registry for the heartbeat's own cron-fire subscriber. */ + /** Listener registry — used to declare the heartbeat producer so its + * agent.work.{requested,skip} emits are validated + show in the + * topology graph. */ registry: ListenerRegistry /** Optional: inject a session for testing. */ session?: SessionStore @@ -91,34 +88,34 @@ export interface HeartbeatOpts { export interface Heartbeat { start(): Promise stop(): void - /** Hot-toggle heartbeat on/off (persists to config + updates cron job). */ + /** Hot-toggle heartbeat on/off (persists to config + updates pump). */ setEnabled(enabled: boolean): Promise /** Current enabled state. */ isEnabled(): boolean - /** Expose the raw listener for direct testing. */ - readonly listener: Listener<'cron.fire', HeartbeatEmits> + /** Manually trigger a heartbeat tick — used by tests and "run now" UI. */ + runNow(): Promise } // ==================== Factory ==================== export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { - const { config, agentWorkListener, cronEngine, registry } = opts + const { config, agentWorkListener, registry } = opts const session = opts.session ?? new SessionStore('heartbeat') const now = opts.now ?? Date.now - let jobId: string | null = null - let processing = false let enabled = config.enabled - let registered = false + let started = false + let producer: ProducerHandle | null = null + let pump: Pump | null = null const dedup = new HeartbeatDedup() // ---- Source config (registered with agent-work-listener) ---- // - // The output-side semantics (notify_user inspection + dedup gate) - // live here, closing over the dedup instance heartbeat owns. The - // agent-work-listener calls these when an agent.work.requested - // event with source='heartbeat' arrives. + // Output-side semantics (notify_user inspection + dedup gate) live + // here, closing over the dedup instance heartbeat owns. The + // agent-work-listener calls these when an agent.work.requested event + // with source='heartbeat' arrives. const sourceConfig: AgentWorkSourceConfig = { source: 'heartbeat', session, @@ -145,90 +142,68 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { onDelivered: (text) => dedup.record(text, now()), } - const listener: Listener<'cron.fire', HeartbeatEmits> = { - name: 'heartbeat', - subscribes: 'cron.fire', - emits: HEARTBEAT_EMITS, - async handle(entry, ctx: ListenerContext) { - const payload = entry.payload - - // Filter to our own cron job - if (payload.jobName !== HEARTBEAT_JOB_NAME) return - - // Serial — preserve today's behaviour. Concurrent heartbeats - // would race on dedup state. - if (processing) return - - processing = true - const startMs = now() - console.log(`heartbeat: firing at ${new Date(startMs).toISOString()}`) - try { - // ---- Pre-emit gate: active-hours ---- - if (!isWithinActiveHours(config.activeHours, now())) { - await ctx.emit('agent.work.skip', { - source: 'heartbeat', - reason: 'outside-active-hours', - }) - console.log(`heartbeat: skipped (outside-active-hours)`) - return - } + /** The pump's tick callback — active-hours guard then emit. */ + async function onTick(): Promise { + const startMs = now() + console.log(`heartbeat: firing at ${new Date(startMs).toISOString()}`) - // ---- Emit canonical request ---- - await ctx.emit('agent.work.requested', { - source: 'heartbeat', - prompt: payload.payload, - }) - } finally { - processing = false - } - }, - } - - /** Ensure the cron job exists and the listener + producer are registered (idempotent). */ - async function ensureJobAndListener(): Promise { - const existing = cronEngine.list().find((j) => j.name === HEARTBEAT_JOB_NAME) - if (existing) { - jobId = existing.id - await cronEngine.update(existing.id, { - schedule: { kind: 'every', every: config.every }, - payload: config.prompt, - enabled, - }) - } else { - jobId = await cronEngine.add({ - name: HEARTBEAT_JOB_NAME, - schedule: { kind: 'every', every: config.every }, - payload: config.prompt, - enabled, + if (!isWithinActiveHours(config.activeHours, now())) { + await producer!.emit('agent.work.skip', { + source: 'heartbeat', + reason: 'outside-active-hours', }) + console.log(`heartbeat: skipped (outside-active-hours)`) + return } - if (!registered) { - registry.register(listener) - agentWorkListener.registerSource(sourceConfig) - registered = true - } + await producer!.emit('agent.work.requested', { + source: 'heartbeat', + prompt: config.prompt, + }) } return { - listener, async start() { - await ensureJobAndListener() + if (started) return + started = true + + producer = registry.declareProducer({ + name: 'heartbeat', + emits: ['agent.work.requested', 'agent.work.skip'] as const, + }) + agentWorkListener.registerSource(sourceConfig) + + pump = createPump({ + name: 'heartbeat', + every: config.every, + enabled, + onTick, + }) + pump.start() }, + stop() { - if (registered) { - registry.unregister(listener.name) - registered = false - } + if (!started) return + pump?.stop() + pump = null + producer?.dispose() + producer = null + started = false }, + async setEnabled(newEnabled: boolean) { enabled = newEnabled - await ensureJobAndListener() + pump?.setEnabled(newEnabled) await writeConfigSection('heartbeat', { ...config, enabled: newEnabled }) }, + isEnabled() { return enabled }, + + async runNow() { + if (pump) await pump.runNow() + }, } } @@ -297,8 +272,7 @@ function currentMinutesInTimezone(tz: string, nowMs?: number): number { * collisions are rare, single-duplicate cost is low. */ export class HeartbeatDedup { - /** Public for callers that want to inspect the last-delivered text - * (e.g. for the agent.work.done payload's metadata). */ + /** Public for callers that want to inspect the last-delivered text. */ public lastText: string | null = null private lastSentAt = 0 private windowMs: number diff --git a/src/task/heartbeat/index.ts b/src/task/heartbeat/index.ts index 9986bbe3..969498cd 100644 --- a/src/task/heartbeat/index.ts +++ b/src/task/heartbeat/index.ts @@ -1,3 +1,3 @@ -export { createHeartbeat, HEARTBEAT_JOB_NAME, DEFAULT_HEARTBEAT_CONFIG } from './heartbeat.js' +export { createHeartbeat, DEFAULT_HEARTBEAT_CONFIG } from './heartbeat.js' export type { Heartbeat, HeartbeatConfig, HeartbeatOpts } from './heartbeat.js' export { isWithinActiveHours, HeartbeatDedup } from './heartbeat.js' From 6b4096ef0bf48f95a22bcaa901939fac80ee79b9 Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 11 May 2026 13:44:18 +0800 Subject: [PATCH 12/12] fix(trading): netLiquidation aggregation correctly subtracts short marketValue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community-reported IBKR options bug ("方向错 + 乘数没到100") root-caused to a shared-layer convention violation: across every broker that self-computes netLiq via `cash + Σ(marketValue)` (Mock / IBKR / CCXT), the unsigned- marketValue convention of `derivePositionMath` made SELL-to-open shorts add their premium to cash AND add their notional marketValue on top — inflating netLiq by 2× the short's marketValue. For OPT this is amplified by the 100x multiplier, which is why community users surfaced it as an options-specific direction bug. The new MockBroker spec confirms three scenarios that all fail pre-fix with exactly "+ 2 × short mv": short OPT (10000 → 11160), short STK (10000 → 20000), mixed (10200 → 12000). Fix collapses the cash+ΣmarketValue formula into `aggregateAccountFromPositions` in position-math.ts, applying side sign during aggregation. Brokers that read upstream-reported equity (Alpaca's `account.equity`, Longbridge's `netAssets`) are unchanged — their upstream APIs already handled shorts correctly. Snapshot type now carries OPT contract metadata (secType, multiplier, strike, right, expiry) — previously persisted positions stripped these, so a UI reading a saved snapshot saw an option-shaped position with no way to tell it was an option. This is the likely source of the "multiplier didn't reach 100" half of the original report. buildPosition adds a loud guard: any OPT/FOP position arriving with multiplier=1 throws — that pattern almost always indicates an upstream broker decode-loss (raw Contract bypassing buildContract's validation because it came from a callback path like IBKR's request-bridge). Catching at the Position boundary prevents silent 100x undercounting in snapshots and netLiq math. TODO.md picks up a Layer 2 entry for the raw-upstream recorder + no-connect replay harness — broker-internal normalize bugs (IBKR's `.abs()`, proto decoder's empty if-body) still need that infrastructure to repro offline, but it's a separate work item. Repro and design notes in ~/.claude/plans/simulator-moonlit-otter.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- TODO.md | 17 ++++ src/domain/trading/brokers/ccxt/CcxtBroker.ts | 10 ++- .../trading/brokers/contract-builder.spec.ts | 59 +++++++++++++ .../trading/brokers/contract-builder.ts | 16 ++++ src/domain/trading/brokers/ibkr/IbkrBroker.ts | 5 +- .../trading/brokers/mock/MockBroker.spec.ts | 82 +++++++++++++++++++ src/domain/trading/brokers/mock/MockBroker.ts | 9 +- src/domain/trading/position-math.spec.ts | 51 +++++++++++- src/domain/trading/position-math.ts | 39 +++++++++ src/domain/trading/snapshot/builder.ts | 6 ++ src/domain/trading/snapshot/snapshot.spec.ts | 33 ++++++++ src/domain/trading/snapshot/types.ts | 10 +++ 12 files changed, 325 insertions(+), 12 deletions(-) diff --git a/TODO.md b/TODO.md index 164c7083..de7cfb6d 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,23 @@ the item when done — git log is the history. ## Architecture +- [ ] Broker raw-upstream recorder + no-connect replay harness (Layer 2 + bug debug surface). When a community user reports a broker-specific + normalize bug — IBKR's `request-bridge.ts:470 .abs()`, the proto + decoder's empty `if (cp.secType !== undefined)` body, a hypothetical + CCXT `entryPrice` mis-parse — code-reading alone is slow and + imprecise. Add a dev-mode raw-upstream recorder per broker (IBKR: + EWrapper callback args; CCXT: `fetchBalance` / `fetchPositions` + return values; Alpaca: REST response bodies), append-only JSONL to + `data/trading//upstream/session-.jsonl`. Pair with a + replay tool that constructs the broker without network (currently + `init()` forces connect) and re-fires the recorded events through + the same normalize pipeline, returning `getPositions` / + `getAccount`. Prerequisite: factor `init()` to allow no-connect + construction for IBKR / CCXT / Alpaca. Lets future broker-bug + diagnosis happen offline against a recorded session instead of + requiring live broker re-attachment. Companion harness ideas in + `~/.claude/plans/simulator-moonlit-otter.md`. - [ ] Extract `derivePositionMath(raw): { marketValue, unrealizedPnL }` shared util. Today's IBroker contract requires every broker's `getPositions` to multiply by `multiplier` when computing diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 3fcfa66e..7ce6e4c3 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -25,6 +25,7 @@ import { type TpSlParams, } from '../types.js' import '../../contract-ext.js' +import { aggregateAccountFromPositions } from '../../position-math.js' import { buildPosition } from '../contract-builder.js' import { CCXT_CREDENTIAL_FIELDS, type CcxtBrokerConfig, type CcxtMarket, type FundingRate, type OrderBook, type OrderBookLevel } from './ccxt-types.js' import { MAX_INIT_RETRIES, INIT_RETRY_BASE_MS } from './ccxt-types.js' @@ -699,7 +700,7 @@ export class CcxtBroker implements IBroker { // snapshot that may not update between funding/settlement cycles). let unrealizedPnL = new Decimal(0) let realizedPnL = new Decimal(0) - let totalPositionValue = new Decimal(0) + const aggregateInputs: Array<{ side: 'long' | 'short'; marketValue: string }> = [] for (const p of rawPositions) { unrealizedPnL = unrealizedPnL.plus(new Decimal(String(p.unrealizedPnl ?? 0))) realizedPnL = realizedPnL.plus(new Decimal(String((p as unknown as Record).realizedPnl ?? 0))) @@ -708,7 +709,8 @@ export class CcxtBroker implements IBroker { const contractSize = new Decimal(String(p.contractSize ?? 1)) const quantity = contracts.mul(contractSize) const markPrice = new Decimal(String(p.markPrice ?? 0)) - totalPositionValue = totalPositionValue.plus(quantity.mul(markPrice)) + const side: 'long' | 'short' = p.side === 'short' ? 'short' : 'long' + aggregateInputs.push({ side, marketValue: quantity.mul(markPrice).toString() }) } // Fold spot holdings (BTC/ETH/etc balances) into position value. @@ -716,10 +718,10 @@ export class CcxtBroker implements IBroker { // an asset — so they count toward netLiquidation, not totalCashValue. const spotHoldings = await this.fetchSpotHoldings(balance) for (const sp of spotHoldings) { - totalPositionValue = totalPositionValue.plus(new Decimal(sp.marketValue)) + aggregateInputs.push({ side: 'long', marketValue: sp.marketValue }) } - const netLiquidation = free.plus(totalPositionValue) + const { netLiquidation } = aggregateAccountFromPositions(free, aggregateInputs) return { baseCurrency: 'USD', diff --git a/src/domain/trading/brokers/contract-builder.spec.ts b/src/domain/trading/brokers/contract-builder.spec.ts index c75196ea..8e4eb81e 100644 --- a/src/domain/trading/brokers/contract-builder.spec.ts +++ b/src/domain/trading/brokers/contract-builder.spec.ts @@ -143,4 +143,63 @@ describe('buildPosition — pass-through vs derive', () => { }) expect(p.avgCostSource).toBe('wallet') }) + + it('throws when OPT contract reaches buildPosition with multiplier=1 (upstream decode bug guard)', async () => { + // Simulates a broker callback path that constructs Contract directly + // (e.g. IBKR's request-bridge populates Contract from EWrapper args, not + // via buildContract+assertContract). If TWS misdecodes the option + // multiplier and the bridge passes through, buildPosition is the last + // line of defense before snapshot persists a 100x-undercount. + const { Contract } = await import('@traderalice/ibkr') + const rawContract = new Contract() + rawContract.symbol = 'AAPL' + rawContract.secType = 'OPT' + rawContract.lastTradeDateOrContractMonth = '20260720' + rawContract.strike = 150 + rawContract.right = 'C' + // No multiplier on the raw Contract — exactly the upstream-decode-loss case. + expect(() => buildPosition({ + contract: rawContract, + currency: 'USD', + side: 'long', + quantity: new Decimal(1), + avgCost: '5', + marketPrice: '5', + realizedPnL: '0', + })).toThrow(/multiplier='1'/) + }) + + it('throws when FOP contract reaches buildPosition with multiplier=1', async () => { + const { Contract } = await import('@traderalice/ibkr') + const rawContract = new Contract() + rawContract.symbol = 'ES' + rawContract.secType = 'FOP' + rawContract.lastTradeDateOrContractMonth = '20260620' + rawContract.strike = 5000 + rawContract.right = 'P' + expect(() => buildPosition({ + contract: rawContract, + currency: 'USD', + side: 'short', + quantity: new Decimal(1), + avgCost: '50', + marketPrice: '50', + realizedPnL: '0', + })).toThrow(/multiplier='1'/) + }) + + it('STK with multiplier=1 is allowed (canonical default)', () => { + const stkContract = buildContract({ + symbol: 'AAPL', secType: 'STK', exchange: 'SMART', currency: 'USD', + }) + expect(() => buildPosition({ + contract: stkContract, + currency: 'USD', + side: 'long', + quantity: new Decimal(100), + avgCost: '150', + marketPrice: '160', + realizedPnL: '0', + })).not.toThrow() + }) }) diff --git a/src/domain/trading/brokers/contract-builder.ts b/src/domain/trading/brokers/contract-builder.ts index f2509648..91b58daa 100644 --- a/src/domain/trading/brokers/contract-builder.ts +++ b/src/domain/trading/brokers/contract-builder.ts @@ -107,6 +107,22 @@ export function buildPosition(input: BuildPositionInput): Position { const multiplier = input.multiplier ?? (input.contract.multiplier ? input.contract.multiplier : '1') + // OPT/FOP positions with multiplier=1 are virtually always an upstream + // decode bug — every real US equity option / futures option has a + // contract multiplier > 1 (typically 100). A passing '1' here means the + // broker either failed to fetch the multiplier or dropped it during + // normalization; downstream marketValue / unrealizedPnL will be wrong + // by ~100x. Refuse to construct the Position rather than ship a silent + // 100x-underreported number. + if ((input.contract.secType === 'OPT' || input.contract.secType === 'FOP') + && (multiplier === '1' || multiplier === '')) { + throw new Error( + `buildPosition: ${input.contract.secType} ${input.contract.symbol ?? '?'} ` + + `has multiplier='${multiplier}' — upstream broker decode is missing the contract ` + + `multiplier. Position values would be off by ~100x.`, + ) + } + // Either pass-through or derive — never both. let marketValue: string let unrealizedPnL: string diff --git a/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 4129dd4f..3fcc16cc 100644 --- a/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -36,6 +36,7 @@ import { type TpSlParams, } from '../types.js' import '../../contract-ext.js' +import { aggregateAccountFromPositions } from '../../position-math.js' import { RequestBridge } from './request-bridge.js' import { resolveSymbol } from './ibkr-contracts.js' import type { IbkrBrokerConfig } from './ibkr-types.js' @@ -267,15 +268,13 @@ export class IbkrBroker implements IBroker { if (!download) throw new BrokerError('NETWORK', 'Account data not yet available') const totalCashValue = new Decimal(download.values.get('TotalCashValue') ?? '0') - let totalMarketValue = new Decimal(0) let positionUnrealizedPnL = new Decimal(0) for (const pos of download.positions) { - totalMarketValue = totalMarketValue.plus(pos.marketValue) positionUnrealizedPnL = positionUnrealizedPnL.plus(pos.unrealizedPnL) } const netLiquidation = download.positions.length > 0 - ? totalCashValue.plus(totalMarketValue) + ? aggregateAccountFromPositions(totalCashValue, download.positions).netLiquidation : new Decimal(download.values.get('NetLiquidation') ?? '0') const unrealizedPnL = download.positions.length > 0 diff --git a/src/domain/trading/brokers/mock/MockBroker.spec.ts b/src/domain/trading/brokers/mock/MockBroker.spec.ts index 31062326..9714487c 100644 --- a/src/domain/trading/brokers/mock/MockBroker.spec.ts +++ b/src/domain/trading/brokers/mock/MockBroker.spec.ts @@ -626,3 +626,85 @@ describe('multiplier discipline (regression)', () => { expect(positions[0].quantity.toNumber()).toBeCloseTo(0.01) }) }) + +// QA finding 2026-05-09: across all brokers that self-compute netLiq via +// `cash + Σ(marketValue)` (Mock / IBKR / CCXT), a SELL-to-open short +// double-counts the position. The premium received is added to cash, AND +// the (unsigned) marketValue is added on top — leaving netLiq inflated by +// 2 × |marketValue|. For OPT this is amplified by the 100x multiplier and +// shows up as the "options direction is wrong" report from the community. +describe('short positions — netLiquidation aggregation', () => { + it('short OPT (SELL to open) leaves netLiquidation flat at mark = entry', async () => { + const acc = new MockBroker({ id: 'mock-paper', cash: 10_000 }) + const optionKey = 'SPY-20260117-P-580' + acc.setMarkPrice(optionKey, '5.80') + acc.externalTrade({ + nativeKey: optionKey, + side: 'SELL', + quantity: '1', + price: '5.80', + contract: { + symbol: 'SPY', secType: 'OPT', localSymbol: optionKey, + lastTradeDateOrContractMonth: '20260117', strike: 580, right: 'P', + multiplier: '100', + }, + }) + + const account = await acc.getAccount() + // Premium received → cash = 10000 + 1×5.80×100 = 10580 + expect(account.totalCashValue).toBe('10580') + // No PnL (mark unchanged from entry), so netLiq should equal the original equity. + // Pre-fix: getAccount returns 11160 (10580 + 580 mv added on top — short ignored). + expect(account.netLiquidation).toBe('10000') + }) + + it('short STK (SELL to open) leaves netLiquidation flat at mark = entry', async () => { + const acc = new MockBroker({ id: 'mock-paper', cash: 10_000 }) + acc.setMarkPrice('NVDA', '100') + acc.externalTrade({ + nativeKey: 'NVDA', + side: 'SELL', + quantity: '50', + price: '100', + contract: { symbol: 'NVDA', secType: 'STK' }, + }) + + const account = await acc.getAccount() + // Short proceeds → cash = 10000 + 50×100 = 15000 + expect(account.totalCashValue).toBe('15000') + // Mark unchanged → netLiq unchanged at 10000. + expect(account.netLiquidation).toBe('10000') + }) + + it('mixed long + short — netLiquidation reflects net equity', async () => { + const acc = new MockBroker({ id: 'mock-paper', cash: 10_000 }) + + // Long 10 NVDA @ $50, mark @ $60 → +$100 unrealized PnL + acc.setMarkPrice('NVDA', '60') + acc.externalTrade({ + nativeKey: 'NVDA', + side: 'BUY', + quantity: '10', + price: '50', + contract: { symbol: 'NVDA', secType: 'STK' }, + }) + // cash now = 10000 - 500 = 9500; long mv at mark = 600 → +100 PnL + + // Short 5 TSLA @ $200, mark @ $180 → +$100 unrealized PnL (mark dropped, good for short) + acc.setMarkPrice('TSLA', '180') + acc.externalTrade({ + nativeKey: 'TSLA', + side: 'SELL', + quantity: '5', + price: '200', + contract: { symbol: 'TSLA', secType: 'STK' }, + }) + // cash now = 9500 + 1000 = 10500; short notional at mark = 900 → +100 PnL + + const account = await acc.getAccount() + // cash = 10500; long contributes +600, short contributes -900 → netLiq = 10500 + 600 - 900 = 10200 + // Equivalently: starting equity 10000 + 100 (long PnL) + 100 (short PnL) = 10200 + expect(account.netLiquidation).toBe('10200') + expect(account.unrealizedPnL).toBe('200') + }) +}) diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index 117e0a0d..3a8a7967 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -31,7 +31,7 @@ import type { TpSlParams, } from '../types.js' import '../../contract-ext.js' -import { derivePositionMath } from '../../position-math.js' +import { derivePositionMath, aggregateAccountFromPositions } from '../../position-math.js' import { buildContract, buildPosition } from '../contract-builder.js' import type { SecType } from '../../contract-discipline.js' @@ -376,7 +376,7 @@ export class MockBroker implements IBroker { if (this._accountOverride) return this._accountOverride let unrealizedPnL = new Decimal(0) - let marketValueAcc = new Decimal(0) + const aggregateInputs: Array<{ side: 'long' | 'short'; marketValue: string }> = [] for (const pos of this._positions.values()) { const price = pos.marketPriceOverride ?? this._markPriceFor(pos.contract) ?? pos.avgCost const { marketValue, unrealizedPnL: pnl } = derivePositionMath({ @@ -386,13 +386,14 @@ export class MockBroker implements IBroker { multiplier: pos.contract.multiplier || '1', side: pos.side, }) - marketValueAcc = marketValueAcc.plus(marketValue) + aggregateInputs.push({ side: pos.side, marketValue }) unrealizedPnL = unrealizedPnL.plus(pnl) } + const { netLiquidation } = aggregateAccountFromPositions(this._cash, aggregateInputs) return { baseCurrency: 'USD', - netLiquidation: this._cash.plus(marketValueAcc).toString(), + netLiquidation: netLiquidation.toString(), totalCashValue: this._cash.toString(), unrealizedPnL: unrealizedPnL.toString(), realizedPnL: this._realizedPnL.toString(), diff --git a/src/domain/trading/position-math.spec.ts b/src/domain/trading/position-math.spec.ts index 869b3550..bef001d4 100644 --- a/src/domain/trading/position-math.spec.ts +++ b/src/domain/trading/position-math.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import Decimal from 'decimal.js' -import { derivePositionMath, pnlOf, multiplierToDecimal } from './position-math.js' +import { derivePositionMath, pnlOf, multiplierToDecimal, aggregateAccountFromPositions } from './position-math.js' describe('derivePositionMath', () => { it('STK long — multiplier=1: marketValue and PnL are simple products', () => { @@ -87,3 +87,52 @@ describe('multiplierToDecimal', () => { expect(multiplierToDecimal('0').toNumber()).toBe(1) }) }) + +describe('aggregateAccountFromPositions', () => { + it('cash only — no positions', () => { + const r = aggregateAccountFromPositions('10000', []) + expect(r.netLiquidation.toString()).toBe('10000') + expect(r.totalMarketValue.toString()).toBe('0') + }) + + it('long-only — netLiq = cash + Σ(marketValue)', () => { + const r = aggregateAccountFromPositions('5000', [ + { side: 'long', marketValue: '1500' }, + { side: 'long', marketValue: '2500' }, + ]) + expect(r.netLiquidation.toString()).toBe('9000') + expect(r.totalMarketValue.toString()).toBe('4000') + }) + + it('short-only — short marketValue subtracts (premium already in cash)', () => { + // Premium received from selling already lives in cash. The short's + // notional marketValue is a liability — subtract from equity. + const r = aggregateAccountFromPositions('10580', [ + { side: 'short', marketValue: '580' }, + ]) + expect(r.netLiquidation.toString()).toBe('10000') + expect(r.totalMarketValue.toString()).toBe('-580') + }) + + it('mixed long + short', () => { + // cash 10500, long mv +600 (NVDA 10@60), short notional 900 (TSLA 5@180) + // → netLiq = 10500 + 600 - 900 = 10200 + const r = aggregateAccountFromPositions('10500', [ + { side: 'long', marketValue: '600' }, + { side: 'short', marketValue: '900' }, + ]) + expect(r.netLiquidation.toString()).toBe('10200') + expect(r.totalMarketValue.toString()).toBe('-300') + }) + + it('accepts Decimal inputs equivalently to strings', () => { + const a = aggregateAccountFromPositions(new Decimal('1000'), [ + { side: 'short', marketValue: new Decimal('200') }, + ]) + const b = aggregateAccountFromPositions('1000', [ + { side: 'short', marketValue: '200' }, + ]) + expect(a.netLiquidation.toString()).toBe(b.netLiquidation.toString()) + expect(a.totalMarketValue.toString()).toBe(b.totalMarketValue.toString()) + }) +}) diff --git a/src/domain/trading/position-math.ts b/src/domain/trading/position-math.ts index d93c9e4d..f34dc5c0 100644 --- a/src/domain/trading/position-math.ts +++ b/src/domain/trading/position-math.ts @@ -77,3 +77,42 @@ export function pnlOf(input: PositionMathInput): string { const sideSign = input.side === 'long' ? 1 : -1 return qty.mul(mark.minus(avg)).mul(mult).mul(sideSign).toString() } + +// ==================== Account aggregation ==================== + +export interface AggregateInput { + side: 'long' | 'short' + marketValue: Decimal | string | number +} + +export interface AggregateOutput { + /** cash + Σ(marketValue × side_sign). */ + netLiquidation: Decimal + /** Σ(marketValue × side_sign). Signed: positive for net-long books, negative for net-short. */ + totalMarketValue: Decimal +} + +/** + * Compute account equity from cash + per-position marketValues. + * + * The `Position.marketValue` convention in this codebase is always-positive + * (notional), with side carried separately. Naively summing those into + * netLiquidation double-counts short positions: a SELL-to-open short adds + * its premium to cash AND its marketValue gets added on top, leaving netLiq + * inflated by 2 × |short marketValue|. This helper applies side sign during + * aggregation so short positions correctly subtract their notional from + * equity. Use it everywhere a broker's getAccount() builds netLiq from + * positions instead of reading an upstream-reported equity field. + */ +export function aggregateAccountFromPositions( + cash: Decimal | string | number, + positions: Iterable, +): AggregateOutput { + const cashDec = toDecimal(cash) + let total = new Decimal(0) + for (const p of positions) { + const mv = toDecimal(p.marketValue) + total = total.plus(p.side === 'short' ? mv.neg() : mv) + } + return { netLiquidation: cashDec.plus(total), totalMarketValue: total } +} diff --git a/src/domain/trading/snapshot/builder.ts b/src/domain/trading/snapshot/builder.ts index 3454fdc4..12c43710 100644 --- a/src/domain/trading/snapshot/builder.ts +++ b/src/domain/trading/snapshot/builder.ts @@ -6,6 +6,7 @@ * Never fabricates zero-value placeholders. */ +import { UNSET_DOUBLE } from '@traderalice/ibkr' import type { UnifiedTradingAccount } from '../UnifiedTradingAccount.js' import type { UTASnapshot, SnapshotTrigger } from './types.js' @@ -50,6 +51,11 @@ export async function buildSnapshot( marketValue: p.marketValue, unrealizedPnL: p.unrealizedPnL, realizedPnL: p.realizedPnL, + ...(p.contract.secType && { secType: p.contract.secType }), + ...(p.multiplier && p.multiplier !== '1' && { multiplier: p.multiplier }), + ...(p.contract.strike != null && p.contract.strike !== UNSET_DOUBLE && { strike: p.contract.strike }), + ...(p.contract.right && { right: p.contract.right }), + ...(p.contract.lastTradeDateOrContractMonth && { expiry: p.contract.lastTradeDateOrContractMonth }), })), openOrders: orders .filter(o => o.orderState.status === 'Submitted' || o.orderState.status === 'PreSubmitted') diff --git a/src/domain/trading/snapshot/snapshot.spec.ts b/src/domain/trading/snapshot/snapshot.spec.ts index b86ebaff..01d32d6e 100644 --- a/src/domain/trading/snapshot/snapshot.spec.ts +++ b/src/domain/trading/snapshot/snapshot.spec.ts @@ -103,6 +103,39 @@ describe('Snapshot Builder', () => { expect(typeof snap!.positions[0].avgCost).toBe('string') }) + // #3b — OPT/FOP metadata round-trips into the snapshot so the UI can + // render strike / right / multiplier badges from a persisted snapshot + // without needing the broker contract registry to be in-memory. + it('OPT positions carry secType + multiplier + strike + right + expiry', async () => { + const optContract = makeContract({ symbol: 'SPY', aliceId: 'mock-paper|SPY-OPT' }) + optContract.secType = 'OPT' + optContract.lastTradeDateOrContractMonth = '20260117' + optContract.strike = 580 + optContract.right = 'P' + optContract.multiplier = '100' + broker.setPositions([makePosition({ contract: optContract, multiplier: '100' })]) + + const snap = await buildSnapshot(uta, 'manual') + expect(snap).not.toBeNull() + const p = snap!.positions[0] + expect(p.secType).toBe('OPT') + expect(p.multiplier).toBe('100') + expect(p.strike).toBe(580) + expect(p.right).toBe('P') + expect(p.expiry).toBe('20260117') + }) + + // STK positions skip the multiplier field (defaults to '1' — would be + // noise in every snapshot row). + it('STK positions omit multiplier when multiplier=1', async () => { + broker.setPositions([makePosition()]) // default STK, multiplier: '1' + const snap = await buildSnapshot(uta, 'manual') + expect(snap).not.toBeNull() + expect(snap!.positions[0]).not.toHaveProperty('multiplier') + expect(snap!.positions[0]).not.toHaveProperty('strike') + expect(snap!.positions[0]).not.toHaveProperty('right') + }) + // #4 it('only includes Submitted/PreSubmitted orders', async () => { const contract = makeContract({ symbol: 'AAPL' }) diff --git a/src/domain/trading/snapshot/types.ts b/src/domain/trading/snapshot/types.ts index 337334be..8892be83 100644 --- a/src/domain/trading/snapshot/types.ts +++ b/src/domain/trading/snapshot/types.ts @@ -37,6 +37,16 @@ export interface UTASnapshot { marketValue: string unrealizedPnL: string realizedPnL: string + /** Contract metadata captured from the broker. Persisted so the UI can + * re-render OPT/FUT/FOP positions (multiplier badge, strike, right, + * expiry tag) without rehydrating the broker's contract registry — + * the broker session that wrote the snapshot may not be running when + * the snapshot is read back. */ + secType?: string + multiplier?: string + strike?: number + right?: string + expiry?: string }> openOrders: Array<{