diff --git a/backend/__tests__/service/taskEventService.offeredAt.test.js b/backend/__tests__/service/taskEventService.offeredAt.test.js index a996a8f234..ea75e544f6 100644 --- a/backend/__tests__/service/taskEventService.offeredAt.test.js +++ b/backend/__tests__/service/taskEventService.offeredAt.test.js @@ -119,6 +119,20 @@ afterAll(async () => { }); describe('kernel sweep offeredAt filter', () => { + it('still nudges unassigned work after its board change reached only digest', async () => { + const row = await createTask({ taskId: 'TASK-UNASSIGNED', updatedAt: new Date(NOW.getTime() - 30000) }); + + await notifyPodAgents(POD_ID, row, 'created', { isAgent: false }); + + expect(AgentEventService.enqueue.mock.calls[0][0].deliveryMode).toBe('digest'); + expect((await Task.findById(row._id).lean()).offeredAt).toBeUndefined(); + const sweep = await KernelWorkSweepService.wakeForFoundWork(NOW); + expect(sweep.woken).toBe(1); + expect(AgentEventService.enqueue.mock.calls[1][0].payload.foundWorkAt).toEqual(NOW); + const after = await Task.findById(row._id).lean(); + expect(after.offeredAt).toEqual(after.updatedAt); + }); + it('still offers a revision deferred past the ten-minute scan window by the hourly cap', async () => { const old = await createTask({ taskId: 'TASK-DEFERRED', updatedAt: new Date(NOW.getTime() - 45 * 60 * 1000), @@ -287,7 +301,8 @@ describe('board wake assigned work', () => { expect(scout.payload.content).toContain('[Your assigned rows (pending/claimed; newest first):]'); expect(scout.payload.content).toContain('TASK-ASSIGN — TASK-ASSIGN [pending] — Lily updated: reassigned to scout'); expect(scout.payload.content).not.toContain('TASK-FOREIGN'); - expect(peer).toBeUndefined(); + expect(peer.deliveryMode).toBe('digest'); + expect(peer.payload.content).toBe('TASK-ASSIGN updated: TASK-ASSIGN'); }); it('keeps the assigned list to five rows, newest first, after a folded change', async () => { @@ -338,12 +353,14 @@ describe('board wake assigned work', () => { expect(anvil.payload.content).toContain('TASK-INSTANCE — Instance Do it now [pending]'); expect(anvil.payload.content).not.toContain('Instance\nDo it now'); expect(anvil.payload.content).not.toContain('TASK-SCOUT'); - expect(scout).toBeUndefined(); + expect(scout.deliveryMode).toBe('digest'); + expect(scout.payload.content).toBe('TASK-INSTANCE updated: Instance Do it now'); }); }); describe('own-row board wake routing', () => { - const wakeNames = () => AgentEventService.enqueue.mock.calls.map(([event]) => event.agentName); + const wakeNames = () => AgentEventService.enqueue.mock.calls + .filter(([event]) => event.deliveryMode !== 'digest').map(([event]) => event.agentName); beforeEach(() => setInstalls([install('scout'), install('sprint-impl')])); @@ -381,7 +398,8 @@ describe('own-row board wake routing', () => { await notifyPodAgents(POD_ID, commented, 'updated', { isAgent: false }); expect(wakeNames()).toEqual(['scout']); - expect(AgentEventService.enqueue.mock.calls[0][0].payload.content).toContain('Peer commented'); + expect(AgentEventService.enqueue.mock.calls + .find(([event]) => event.deliveryMode !== 'digest')[0].payload.content).toContain('Peer commented'); }); it.each(['native', 'durable-object'])('sends a hosted %s owner a type its engine runs', async (engine) => { @@ -394,7 +412,8 @@ describe('own-row board wake routing', () => { await notifyPodAgents(POD_ID, changed, 'updated', { isAgent: false }); expect(wakeNames()).toEqual(['maya-agent']); - expect(AgentEventService.enqueue.mock.calls[0][0].type).toBe('chat.mention'); + expect(AgentEventService.enqueue.mock.calls + .find(([event]) => event.deliveryMode !== 'digest')[0].type).toBe('chat.mention'); }); it('inherits hosted runtime for a mention-created pod projection', async () => { diff --git a/backend/__tests__/unit/models/AgentEvent.deliveryMode.test.js b/backend/__tests__/unit/models/AgentEvent.deliveryMode.test.js new file mode 100644 index 0000000000..a65eac6d38 --- /dev/null +++ b/backend/__tests__/unit/models/AgentEvent.deliveryMode.test.js @@ -0,0 +1,14 @@ +const mongoose = require('mongoose'); +const AgentEvent = require('../../../models/AgentEvent'); + +describe('AgentEvent delivery mode persistence shape', () => { + const base = { + agentName: 'flint', instanceId: 'default', podId: new mongoose.Types.ObjectId(), + type: 'message.posted', status: 'pending', attempts: 0, + }; + + test('legacy callers default to wake; digest survives strict schema conversion', () => { + expect(new AgentEvent(base).toObject().deliveryMode).toBe('wake'); + expect(new AgentEvent({ ...base, deliveryMode: 'digest' }).toObject().deliveryMode).toBe('digest'); + }); +}); diff --git a/backend/__tests__/unit/services/agentDigestFoldService.test.js b/backend/__tests__/unit/services/agentDigestFoldService.test.js new file mode 100644 index 0000000000..9ada0768c8 --- /dev/null +++ b/backend/__tests__/unit/services/agentDigestFoldService.test.js @@ -0,0 +1,217 @@ +const mongoose = require('mongoose'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils'); +const AgentEvent = require('../../../models/AgentEvent'); +const AgentDigestState = require('../../../models/AgentDigestState'); +const AgentEventService = require('../../../services/agentEventService'); +const { foldExpiredDigests, readFoldedDigest, settleFoldedDigest } = require('../../../services/agentDigestFoldService'); + +const NOW = new Date('2026-10-23T23:00:00.000Z'); +const THRESHOLD = new Date(NOW.getTime() - 60 * 60 * 1000); + +describe('expired digest fold', () => { + beforeAll(async () => { + await setupMongoDb(); + await AgentDigestState.init(); + }); + afterAll(closeMongoDb); + afterEach(async () => { + jest.restoreAllMocks(); + await clearMongoDb(); + }); + + const seed = async (createdAt, podId, payload = {}) => AgentEvent.collection.insertOne({ + agentName: 'flint', instanceId: 'default', podId, + type: 'message.posted', deliveryMode: 'digest', status: 'pending', + payload, + attempts: 0, createdAt, updatedAt: createdAt, + }); + + it('drains multiple batches within one GC pass', async () => { + const podId = new mongoose.Types.ObjectId(); + await Promise.all(Array.from({ length: 205 }, () => seed(new Date(THRESHOLD.getTime() - 1000), podId))); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(205); + + expect(await AgentEvent.countDocuments({ podId })).toBe(0); + expect((await AgentDigestState.findOne({ podId }).lean()).unreadCount).toBe(205); + }); + + it('stops at its time budget and leaves remaining rows due for the next pass', async () => { + const podId = new mongoose.Types.ObjectId(); + await Promise.all(Array.from({ length: 4 }, () => seed(new Date(THRESHOLD.getTime() - 1000), podId))); + let elapsed = 0; + const realDelete = AgentEvent.deleteOne.bind(AgentEvent); + const deletion = jest.spyOn(AgentEvent, 'deleteOne').mockImplementation(async (...args) => { + const result = await realDelete(...args); + elapsed += 5000; + return result; + }); + + expect(await foldExpiredDigests(THRESHOLD, () => elapsed)).toBe(2); + expect(await AgentEvent.countDocuments({ podId })).toBe(2); + deletion.mockRestore(); + expect(await foldExpiredDigests(THRESHOLD)).toBe(2); + expect((await AgentDigestState.findOne({ podId }).lean()).unreadCount).toBe(4); + }); + + it('defers a contended scope without starving another scope beyond the first batch', async () => { + const blockedPod = new mongoose.Types.ObjectId(); + const healthyPod = new mongoose.Types.ObjectId(); + const old = new Date(THRESHOLD.getTime() - 1000); + await Promise.all(Array.from({ length: 102 }, () => seed(old, blockedPod))); + await seed(old, healthyPod); + await AgentDigestState.create({ agentName: 'flint', instanceId: 'default', podId: blockedPod }); + const realUpdate = AgentDigestState.updateOne.bind(AgentDigestState); + const contention = jest.spyOn(AgentDigestState, 'updateOne').mockImplementation((filter, ...args) => ( + String(filter.podId) === String(blockedPod) + ? Promise.resolve({ modifiedCount: 0 }) : realUpdate(filter, ...args) + )); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(1); + expect(await AgentEvent.countDocuments({ podId: blockedPod })).toBe(102); + expect((await AgentDigestState.findOne({ podId: healthyPod }).lean()).unreadCount).toBe(1); + contention.mockRestore(); + expect(await foldExpiredDigests(THRESHOLD)).toBe(102); + expect((await AgentDigestState.findOne({ podId: blockedPod }).lean()).unreadCount).toBe(102); + }); + + it('retains unread count and last IDs while deleting expired full events', async () => { + const podId = new mongoose.Types.ObjectId(); + const first = await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + const second = await seed(new Date(NOW.getTime() - 90 * 60 * 1000), podId); + const recent = await seed(NOW, podId); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(2); + const state = await AgentDigestState.findOne({ agentName: 'flint', podId }).lean(); + expect(state.unreadCount).toBe(2); + expect(state.unreadSince).toEqual(new Date(NOW.getTime() - 2 * 60 * 60 * 1000)); + expect(state.lastEventIds).toEqual([String(first.insertedId), String(second.insertedId)]); + expect(state.conversations).toEqual([{ label: 'pod conversation', count: 2 }]); + expect(await AgentEvent.exists({ _id: recent.insertedId })).toBeTruthy(); + expect(await AgentEvent.countDocuments({ podId })).toBe(1); + }); + + it('retains conversation counts and order after full event bodies expire', async () => { + const podId = new mongoose.Types.ObjectId(); + const old = new Date(NOW.getTime() - 2 * 60 * 60 * 1000); + await seed(old, podId, { threadRootId: 40, content: 'first body' }); + await seed(old, podId, { threadRootId: 40, content: 'second body' }); + await seed(old, podId, { taskId: 'TASK-7', content: 'board text' }); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(3); + const state = await readFoldedDigest({ agentName: 'flint', instanceId: 'default', podId }); + expect(state.conversations).toEqual([ + { label: 'thread 40', count: 2 }, + { label: 'TASK-7', count: 1 }, + ]); + expect(state.otherCount).toBe(0); + expect(JSON.stringify(state)).not.toContain('body'); + expect(JSON.stringify(state)).not.toContain('board text'); + }); + + it('folds old unacked deliveries and failures before ordinary GC can delete them', async () => { + const podId = new mongoose.Types.ObjectId(); + const delivered = await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + const failed = await seed(new Date(NOW.getTime() - 90 * 60 * 1000), podId); + await AgentEvent.collection.updateOne({ _id: delivered.insertedId }, { $set: { status: 'delivered' } }); + await AgentEvent.collection.updateOne({ _id: failed.insertedId }, { $set: { status: 'failed' } }); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(2); + expect((await AgentDigestState.findOne({ agentName: 'flint', podId }).lean()).unreadCount).toBe(2); + }); + + it('does not count a row twice after a crash between state update and delete', async () => { + const podId = new mongoose.Types.ObjectId(); + const event = await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + const deleteOne = jest.spyOn(AgentEvent, 'deleteOne').mockRejectedValueOnce(new Error('lost connection')); + await expect(foldExpiredDigests(THRESHOLD)).rejects.toThrow('lost connection'); + deleteOne.mockRestore(); + expect(await AgentEvent.findById(event.insertedId).lean()).toMatchObject({ status: 'folding' }); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(1); + const state = await AgentDigestState.findOne({ agentName: 'flint', podId }).lean(); + expect(state.unreadCount).toBe(1); + expect(state.lastEventIds).toEqual([String(event.insertedId)]); + }); + + it('counts each expired row once when two GC workers overlap', async () => { + const podId = new mongoose.Types.ObjectId(); + await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + await seed(new Date(NOW.getTime() - 90 * 60 * 1000), podId); + + const deleted = await Promise.all([foldExpiredDigests(THRESHOLD), foldExpiredDigests(THRESHOLD)]); + expect(deleted[0] + deleted[1]).toBe(2); + const state = await AgentDigestState.findOne({ agentName: 'flint', podId }).lean(); + expect(state.unreadCount).toBe(2); + expect(await AgentEvent.countDocuments({ podId })).toBe(0); + }); + + it('does not advance the cursor past an older row another worker claimed', async () => { + const podId = new mongoose.Types.ObjectId(); + await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + await seed(new Date(NOW.getTime() - 90 * 60 * 1000), podId); + const claim = jest.spyOn(AgentEvent, 'findOneAndUpdate').mockResolvedValueOnce(null); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(0); + expect(await AgentDigestState.findOne({ agentName: 'flint', podId })).toBeNull(); + claim.mockRestore(); + expect(await foldExpiredDigests(THRESHOLD)).toBe(2); + }); + + it('waits when an older ID has a newer retention timestamp', async () => { + const podId = new mongoose.Types.ObjectId(); + const olderId = await seed(NOW, podId); + await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + + expect(await foldExpiredDigests(THRESHOLD)).toBe(0); + expect(await AgentDigestState.findOne({ agentName: 'flint', podId })).toBeNull(); + await AgentEvent.collection.updateOne( + { _id: olderId.insertedId }, + { $set: { createdAt: new Date(NOW.getTime() - 3 * 60 * 60 * 1000) } }, + ); + expect(await foldExpiredDigests(THRESHOLD)).toBe(2); + }); + + it('settles the exact seen revision while preserving a newer fold', async () => { + const podId = new mongoose.Types.ObjectId(); + const scope = { agentName: 'flint', instanceId: 'default', podId }; + await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + await foldExpiredDigests(THRESHOLD); + const first = await readFoldedDigest(scope); + expect(first).toMatchObject({ unreadCount: 1, revision: 1 }); + + await seed(new Date(NOW.getTime() - 90 * 60 * 1000), podId); + await foldExpiredDigests(THRESHOLD); + await settleFoldedDigest(scope, first.revision); + expect((await readFoldedDigest(scope)).unreadCount).toBe(2); + + const latest = await readFoldedDigest(scope); + await settleFoldedDigest(scope, latest.revision); + expect(await readFoldedDigest(scope)).toBeNull(); + expect((await AgentDigestState.findOne(scope).lean()).revision).toBe(3); + expect((await AgentDigestState.findOne(scope).lean()).unreadSince).toBeNull(); + + const nextUnreadAt = new Date(NOW.getTime() - 70 * 60 * 1000); + await seed(nextUnreadAt, podId); + await foldExpiredDigests(THRESHOLD); + const nextState = await AgentDigestState.findOne(scope).lean(); + expect(nextState.unreadCount).toBe(1); + expect(nextState.unreadSince).toEqual(nextUnreadAt); + }); + + it('attaches the folded summary to a real wake and clears it on that wake ack', async () => { + const podId = new mongoose.Types.ObjectId(); + const scope = { agentName: 'flint', instanceId: 'default', podId }; + await seed(new Date(NOW.getTime() - 2 * 60 * 60 * 1000), podId); + await foldExpiredDigests(THRESHOLD); + const wake = await AgentEvent.create({ ...scope, type: 'heartbeat', + status: 'delivered', deliveryMode: 'wake', attempts: 1, deliveryNonce: 'nonce-1' }); + + expect(await AgentEventService.attachFoldedDigest(wake)).toMatchObject({ + unreadCount: 1, conversations: [{ label: 'pod conversation', count: 1 }], + }); + expect((await AgentEvent.findById(wake._id).lean()).digestRollupRevision).toBe(1); + await AgentEventService.acknowledge(wake._id, 'flint', 'default', { outcome: 'no_action' }, 'nonce-1', 'cli'); + expect(await readFoldedDigest(scope)).toBeNull(); + }); +}); diff --git a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js index 69ace026ec..a496ecbfb7 100644 --- a/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js +++ b/backend/__tests__/unit/services/agentEventService.deliveryNonce.test.js @@ -39,6 +39,7 @@ jest.mock('../../../services/nativeRuntimeService', () => ({ runAgent: jest.fn() let mongod; let AgentEvent; let AgentMemory; +let AgentDigestState; let AgentEventService; const AGENT = 'pixel'; @@ -50,6 +51,7 @@ beforeAll(async () => { await mongoose.connect(mongod.getUri()); AgentEvent = require('../../../models/AgentEvent'); AgentMemory = require('../../../models/AgentMemory'); + AgentDigestState = require('../../../models/AgentDigestState'); AgentEventService = require('../../../services/agentEventService'); }); @@ -61,6 +63,200 @@ afterAll(async () => { beforeEach(async () => { await AgentEvent.deleteMany({}); await AgentMemory.deleteMany({}); + await AgentDigestState.deleteMany({}); +}); + +describe('legacy ambient digest bridge', () => { + test('a push hint does not consume durable inbox state before the reconciling poll', async () => { + const ws = require('../../../services/agentWebSocketService'); + const push = jest.spyOn(ws, 'pushEvent').mockReturnValue(true); + const digest = await AgentEventService.enqueue({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', + payload: { username: 'Alice', content: 'context' }, + }); + const wake = await AgentEventService.enqueue({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'chat.mention', payload: { content: 'question' }, + }); + expect(push).toHaveBeenCalledTimes(1); + expect(String(push.mock.calls[0][0]._id)).toBe(String(wake._id)); + expect((await AgentEvent.findById(digest._id).lean()).status).toBe('pending'); + expect((await AgentEvent.findById(wake._id).lean()).status).toBe('pending'); + + const page = await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 10, + }); + expect(page.events.map((item) => String(item._id))).toEqual([String(wake._id), String(digest._id)]); + expect((await AgentEvent.findById(wake._id).lean()).attempts).toBe(1); + expect((await AgentEvent.findById(digest._id).lean()).attempts).toBe(0); + expect((await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 10, + })).events).toEqual([]); + push.mockRestore(); + }); + + test('a full wake page reserves one slot for unread digest from the same pod', async () => { + const firstWake = await seedEvent(); + const secondWake = await seedEvent(); + const digest = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', + payload: { messageId: '42', threadRootId: 21, content: 'private ambient body' }, + }); + + const page = await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 2, + }); + expect(page.inboxCount).toBe(3); + expect(page.events.map((event) => String(event._id))) + .toEqual([String(firstWake._id), String(digest._id)]); + expect((await AgentEvent.findById(secondWake._id).lean()).status).toBe('pending'); + expect((await AgentEvent.findById(digest._id).lean()).status).toBe('delivered'); + }); + + test('a full wake page keeps every wake when there is no digest', async () => { + const firstWake = await seedEvent(); + const secondWake = await seedEvent(); + const page = await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 2, + }); + expect(page.events.map((event) => String(event._id))) + .toEqual([String(firstWake._id), String(secondWake._id)]); + }); + + test('a one-wake page still carries its unread digest companion', async () => { + const wake = await seedEvent(); + const digest = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', payload: { messageId: '42' }, + }); + const page = await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 1, + }); + expect(page.events.map((event) => String(event._id))) + .toEqual([String(wake._id), String(digest._id)]); + }); + + test('a poll after pod access is removed cannot drain a queued wake or digest', async () => { + const digest = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', payload: { content: 'private update' }, + }); + const wake = await seedEvent(); + + expect(await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [], + })).toEqual({ events: [], inboxCount: 0 }); + expect(await AgentEventService.list({ + agentName: AGENT, instanceId: INSTANCE, podIds: [], + })).toEqual([]); + expect((await AgentEvent.findById(wake._id).lean()).status).toBe('pending'); + expect((await AgentEvent.findById(digest._id).lean()).status).toBe('pending'); + }); + + test('a legacy poll carries the pending digest on its wake and settles it after ack', async () => { + const ambient = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', + payload: { username: 'Alice', messageId: '42', content: 'ambient note' }, + }); + const wake = await seedEvent(); + await AgentEvent.updateOne({ _id: wake._id }, { $set: { payload: { content: 'question' } } }); + + const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); + expect(String(delivered._id)).toBe(String(wake._id)); + expect(delivered.payload.digestSummary).toContain('pod conversation: 1 unread'); + expect(delivered.payload.digestSummary).not.toContain('ambient note'); + expect(delivered.payload.content).toContain('pod conversation: 1 unread'); + expect(delivered.payload.content).not.toContain('ambient note'); + expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('pending'); + + await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, + { outcome: 'no_action' }, delivered.payload.deliveryId); + expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('acked'); + }); + + test('a failed legacy wake leaves its digest unread for a later wake', async () => { + const ambient = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', payload: { content: 'later' }, + }); + const wake = await seedEvent(); + const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); + await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, + { outcome: 'error' }, delivered.payload.deliveryId); + expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('pending'); + }); + + test('a legacy wake without content still exposes digest to the consumer', async () => { + await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', + payload: { username: 'Alice', content: 'ambient note' }, + }); + await seedEvent(); // payload.text only; older consumers read payload.content. + + const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); + expect(delivered.payload.content).toContain('pod conversation: 1 unread'); + expect(delivered.payload.content).not.toContain('ambient note'); + }); + + test('a claim stand-down does not consume raw or folded digest', async () => { + const ambient = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', payload: { content: 'later' }, + }); + await AgentDigestState.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + unreadCount: 2, revision: 1, lastEventIds: ['old-1', 'old-2'], + }); + const wake = await seedEvent(); + const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); + await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, + { outcome: 'no_action', reason: 'claim-held' }, delivered.payload.deliveryId); + + expect((await AgentEvent.findById(ambient._id).lean()).status).toBe('pending'); + expect((await AgentDigestState.findOne({ agentName: AGENT, podId: POD_ID }).lean()).unreadCount).toBe(2); + }); + + test('repeated refused wakes do not exhaust the digest delivery cap', async () => { + const digest = await AgentEvent.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + type: 'message.posted', deliveryMode: 'digest', payload: { content: 'still unread' }, + }); + for (let index = 0; index < 4; index += 1) { + const wake = await seedEvent(); + const page = await AgentEventService.listInboxPage({ + agentName: AGENT, instanceId: INSTANCE, podIds: [POD_ID], limit: 10, + }); + expect(page.events.map((event) => String(event._id))) + .toEqual([String(wake._id), String(digest._id)]); + await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, + { outcome: 'no_action', reason: 'claim-held' }, page.events[0].deliveryNonce); + await AgentEvent.updateOne({ _id: digest._id }, { + $set: { status: 'pending', deliveryNonce: null, deliveredAt: null }, + }); + } + expect((await AgentEvent.findById(digest._id).lean()).attempts).toBe(0); + expect((await AgentEvent.findById(digest._id).lean()).status).toBe('pending'); + }); + + test('a legacy wake carries a folded count and clears only the revision it showed', async () => { + await AgentDigestState.create({ + agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, + unreadCount: 3, revision: 1, lastEventIds: ['old-1', 'old-2', 'old-3'], + }); + const wake = await seedEvent(); + const [delivered] = await AgentEventService.list({ agentName: AGENT, instanceId: INSTANCE }); + expect(delivered.payload.digestSummary).toContain('older pod activity: 3 unread'); + await AgentDigestState.updateOne({ agentName: AGENT, podId: POD_ID }, + { $inc: { unreadCount: 1, revision: 1 } }); + + await AgentEventService.acknowledge(wake._id, AGENT, INSTANCE, + { outcome: 'no_action' }, delivered.payload.deliveryId); + expect((await AgentDigestState.findOne({ agentName: AGENT, podId: POD_ID }).lean()).unreadCount).toBe(4); + }); }); const seedEvent = () => AgentEvent.create({ @@ -483,6 +679,13 @@ describe('the sweep re-runs a stuck in-process event', () => { const event = await AgentEventService.enqueue({ agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, type: 'chat.mention', payload: { text: 'hi' }, }); + // Native dispatch now gathers pending ambient digest before calling the + // runner. Wait for that asynchronous read so it cannot leak into the next + // test or make an immediate runAgent assertion race the database. + for (let i = 0; i < 50 && runAgent.mock.calls.length === 0; i += 1) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } const born = await AgentEvent.findById(event._id).lean(); return { event, born, settles }; }; @@ -523,6 +726,11 @@ describe('the sweep re-runs a stuck in-process event', () => { AgentEventService.redispatchStuckInProcess(threshold, 3), ]); + for (let i = 0; i < 50 && runAgent.mock.calls.length < 2; i += 1) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(results.reduce((sum, r) => sum + r.redispatched, 0)).toBe(1); expect(runAgent).toHaveBeenCalledTimes(2); // the original turn plus one re-run }); @@ -720,6 +928,11 @@ describe('the sweep runs a pending event stranded by a move in-process', () => { AgentEventService.rescueStrandedPendingInProcess(new Date(), 3), ]); + for (let i = 0; i < 50 && runAgent.mock.calls.length === 0; i += 1) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(counts[0].rescued + counts[1].rescued).toBe(1); expect(runAgent).toHaveBeenCalledTimes(1); }); diff --git a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js index cc5e1ab410..d5da555da4 100644 --- a/backend/__tests__/unit/services/agentEventService.lifecycle.test.js +++ b/backend/__tests__/unit/services/agentEventService.lifecycle.test.js @@ -28,11 +28,18 @@ jest.mock('../../../models/AgentEvent', () => ({ create: jest.fn(), findOneAndUpdate: jest.fn(), findByIdAndUpdate: jest.fn(), + updateOne: jest.fn(), find: jest.fn(), updateMany: jest.fn(), deleteMany: jest.fn(), })); +jest.mock('../../../services/agentDigestFoldService', () => ({ + foldExpiredDigests: jest.fn().mockResolvedValue(0), + readFoldedDigest: jest.fn().mockResolvedValue(null), + settleFoldedDigest: jest.fn().mockResolvedValue(undefined), +})); + jest.mock('../../../models/AgentMemory', () => ({ findOne: jest.fn(), updateOne: jest.fn(), @@ -76,6 +83,7 @@ const Task = require('../../../models/Task'); const { AgentInstallation } = require('../../../models/AgentRegistry'); const AgentEventService = require('../../../services/agentEventService'); const { runAgent } = require('../../../services/nativeRuntimeService'); +const { settleFoldedDigest } = require('../../../services/agentDigestFoldService'); const { postToPublicAddress } = require('../../../services/outboundUrlGuard'); // Let fire-and-forget promise chains (webhook delivery, native settle) run. @@ -135,6 +143,20 @@ describe('attempts is a delivery counter with exactly one writer', () => { expect(update.$inc).toBeUndefined(); }); + test('a no-turn ack leaves folded digest and the memory read cursor unread', async () => { + AgentEvent.findOneAndUpdate.mockResolvedValue({ + _id: 'evt-1', agentName: 'pixel', instanceId: 'default', podId: POD_P1, + type: 'heartbeat', status: 'acked', digestRollupRevision: 3, + memoryRevisionAtDelivery: 7, + }); + + await AgentEventService.acknowledge('evt-1', 'pixel', 'default', + { outcome: 'no_action', reason: 'native-no-turn' }); + + expect(settleFoldedDigest).not.toHaveBeenCalled(); + expect(AgentMemory.updateOne).not.toHaveBeenCalled(); + }); + test('recordFailure() does NOT increment either', async () => { AgentEvent.findOneAndUpdate.mockResolvedValue({ _id: 'evt-1', agentName: 'pixel', instanceId: 'default', podId: POD_P1, @@ -326,13 +348,14 @@ describe('garbageCollect: the requeue predicate and its cap', () => { await AgentEventService.garbageCollect({}); const pending = deletes().find((f) => f.status === 'pending'); - const settled = deletes().find((f) => Array.isArray(f.status?.$in)); + const settled = deletes().find((f) => f.status === 'acked'); expect(pending).toBeDefined(); expect(settled).toBeDefined(); // Same instant, not merely "both long": a shorter pending horizon is exactly // what let the sweep outrun the retry lifecycle. expect(pending.createdAt.$lt.getTime()).toBe(settled.createdAt.$lt.getTime()); + expect(pending.deliveryMode).toEqual({ $ne: 'digest' }); }); test('no delete predicate can destroy a pending event inside the retry window', async () => { @@ -397,6 +420,40 @@ describe('driver paths reach a terminal state', () => { expect(update['delivery.outcome']).toBe('no_action'); }); + test.each([{ text: 'original text' }, { prompt: 'original prompt' }])('a webhook wake preserves fallback text and settles context after accepted delivery: %j', async (wakePayload) => { + const ambient = { + _id: 'ambient-1', agentName: 'hookbot', instanceId: 'default', podId: POD_P1, + payload: { username: 'Alice', messageId: '42', threadRootId: 40, content: 'ambient note' }, + }; + const duplicate = { ...ambient, _id: 'ambient-2' }; + AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: async () => [ambient, duplicate] }) }) }); + AgentEvent.updateOne.mockResolvedValue({ matchedCount: 1 }); + AgentEvent.updateMany.mockResolvedValue({ modifiedCount: 1 }); + AgentEvent.create.mockResolvedValue({ + _id: 'evt-digest-wake', agentName: 'hookbot', instanceId: 'default', podId: POD_P1, + type: 'chat.mention', payload: wakePayload, status: 'pending', attempts: 0, + }); + AgentInstallation.find.mockReturnValue({ + lean: async () => [{ config: { runtime: { webhookUrl: 'https://example.test/hook' } } }], + }); + postToPublicAddress.mockResolvedValueOnce({ ok: true, status: 200, text: '{"outcome":"no_action"}' }); + + await AgentEventService.enqueue({ + agentName: 'hookbot', instanceId: 'default', podId: POD_P1, type: 'chat.mention', payload: {}, + }); + await flush(); + + const body = JSON.parse(postToPublicAddress.mock.calls[0][1].body); + expect(body.payload.content).toContain(wakePayload.prompt || wakePayload.text); + expect(body.payload.digestSummary).toContain('thread 40: 1 unread'); + expect(body.payload.digestSummary).toContain('1 ambient update in 1 conversation'); + expect(body.payload.digestSummary).not.toContain('ambient note'); + expect(AgentEvent.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ _id: { $in: ['ambient-1', 'ambient-2'] }, deliveryMode: 'digest' }), + expect.objectContaining({ $set: expect.objectContaining({ status: 'acked' }) }), + ); + }); + test('a webhook URL the guard refuses gets no request, and the event is not acked', async () => { // The real guard runs here (no override): the address is link-local. // Fail instead of connecting, so even a broken guard cannot open a socket here. @@ -448,6 +505,58 @@ describe('driver paths reach a terminal state', () => { await flush(); }; + test('a digest for a native installation remains pending without starting a turn', async () => { + AgentInstallation.findOne.mockReturnValue({ lean: () => Promise.resolve(nativeInstall) }); + AgentEvent.create.mockResolvedValue({ + _id: 'evt-digest', agentName: 'clerk', instanceId: 'default', podId: POD_P1, + type: 'message.posted', payload: {}, status: 'pending', deliveryMode: 'digest', + }); + + await AgentEventService.enqueue({ + agentName: 'clerk', instanceId: 'default', podId: POD_P1, + type: 'message.posted', payload: {}, deliveryMode: 'digest', + }); + + expect(AgentEvent.create).toHaveBeenCalledWith(expect.objectContaining({ deliveryMode: 'digest' })); + expect(AgentEvent.create.mock.calls[0][0]).not.toHaveProperty('status'); + expect(runAgent).not.toHaveBeenCalled(); + }); + + test('a later native wake carries digest context and settles only included rows on success', async () => { + const digest = { _id: 'evt-d', payload: { username: 'alice', messageId: '42', content: 'daily update' } }; + AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: () => Promise.resolve([digest]) }) }) }); + AgentEvent.updateMany.mockResolvedValue({ modifiedCount: 1 }); + runAgent.mockResolvedValue({ status: 'succeeded', runId: 'run-1', totalTurns: 1 }); + + await enqueueNative(); + + expect(runAgent.mock.calls[0][1].payload.content).toContain('pod conversation: 1 unread'); + expect(runAgent.mock.calls[0][1].payload.content).not.toContain('daily update'); + expect(AgentEvent.updateMany).toHaveBeenCalledWith( + { _id: { $in: ['evt-d'] }, status: 'pending', deliveryMode: 'digest' }, + { $set: { status: 'acked' } }, + ); + + jest.clearAllMocks(); + AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: () => Promise.resolve([digest]) }) }) }); + runAgent.mockResolvedValue({ status: 'failed' }); + await enqueueNative(); + expect(AgentEvent.updateMany).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + AgentEvent.find.mockReturnValue({ sort: () => ({ limit: () => ({ lean: () => Promise.resolve([digest]) }) }) }); + runAgent.mockResolvedValue({ status: 'succeeded', runId: '', totalTurns: 0 }); + const acknowledge = jest.spyOn(AgentEventService, 'acknowledge').mockResolvedValue({}); + await enqueueNative(); + expect(AgentEvent.updateMany).not.toHaveBeenCalled(); + expect(acknowledge).toHaveBeenCalledWith( + 'evt-n', 'clerk', 'default', + { outcome: 'no_action', reason: 'native-no-turn' }, + null, 'native', + ); + acknowledge.mockRestore(); + }); + test('a podId shaped like a query operator is never looked up or run in-process', async () => { AgentInstallation.findOne.mockReturnValue({ lean: () => Promise.resolve(nativeInstall) }); AgentEvent.create.mockResolvedValue({ _id: 'evt-x', agentName: 'clerk', instanceId: 'default', type: 'chat.mention', payload: {} }); diff --git a/backend/__tests__/unit/services/agentEventService.test.js b/backend/__tests__/unit/services/agentEventService.test.js index e0a36e3c4e..412617ac6e 100644 --- a/backend/__tests__/unit/services/agentEventService.test.js +++ b/backend/__tests__/unit/services/agentEventService.test.js @@ -2,9 +2,16 @@ jest.mock('../../../models/AgentEvent', () => ({ create: jest.fn(), countDocuments: jest.fn(), findOneAndUpdate: jest.fn(), + updateOne: jest.fn(), find: jest.fn(), })); +jest.mock('../../../services/agentDigestFoldService', () => ({ + foldExpiredDigests: jest.fn().mockResolvedValue(0), + readFoldedDigest: jest.fn().mockResolvedValue(null), + settleFoldedDigest: jest.fn().mockResolvedValue(undefined), +})); + jest.mock('../../../models/AgentMemory', () => ({ findOne: jest.fn(), updateOne: jest.fn(), @@ -319,7 +326,8 @@ describe('AgentEventService', () => { // covers pod-b too. AgentEvent.find .mockReturnValueOnce(query([{ _id: 'head', podId: 'pod-a' }])) - .mockReturnValueOnce(query([{ _id: 'head' }, { _id: 'same-pod' }])); + .mockReturnValueOnce(query([{ _id: 'head' }, { _id: 'same-pod' }])) + .mockReturnValueOnce(query([])); AgentEvent.countDocuments.mockResolvedValue(2); AgentMemory.findOne.mockReturnValue({ select: () => ({ lean: jest.fn().mockResolvedValue({ revision: 1, lastSeenRevision: 0 }) }), @@ -346,6 +354,7 @@ describe('AgentEventService', () => { $and: expect.arrayContaining([ expect.objectContaining({ podId: { $in: ['pod-a', 'pod-b'] } }), { podId: { $exists: true, $ne: null } }, + { deliveryMode: { $ne: 'digest' } }, ]), })); expect(AgentEvent.find).toHaveBeenNthCalledWith(2, expect.objectContaining({ podId: 'pod-a' })); @@ -378,12 +387,74 @@ describe('AgentEventService', () => { podId: { $in: ['pod-a', 'pod-b'] }, }, { podId: { $exists: true, $ne: null } }, + { deliveryMode: { $ne: 'digest' } }, ], }); expect(AgentEvent.countDocuments).not.toHaveBeenCalled(); expect(AgentEvent.findOneAndUpdate).not.toHaveBeenCalled(); }); + test('digest-only inbox stays pending; a later wake claims it in the same pod', async () => { + const AgentMemory = require('../../../models/AgentMemory'); + const { readFoldedDigest } = require('../../../services/agentDigestFoldService'); + const query = (rows) => ({ + sort: () => ({ limit: () => ({ select: () => ({ lean: jest.fn().mockResolvedValue(rows) }) }) }), + }); + AgentEvent.find.mockReturnValueOnce(query([])); + expect(await AgentEventService.listInboxPage({ agentName: 'inbox', podId: 'pod-a' })) + .toEqual({ events: [], inboxCount: 0 }); + expect(AgentEvent.findOneAndUpdate).not.toHaveBeenCalled(); + + AgentEvent.find + .mockReturnValueOnce(query([{ _id: 'wake', podId: 'pod-a' }])) + .mockReturnValueOnce(query([{ _id: 'wake' }])) + .mockReturnValueOnce(query([{ _id: 'digest' }])); + AgentEvent.countDocuments.mockResolvedValue(2); + AgentMemory.findOne.mockReturnValue({ + select: () => ({ lean: jest.fn().mockResolvedValue({ revision: 1, lastSeenRevision: 0 }) }), + }); + AgentEvent.findOneAndUpdate + .mockReturnValueOnce({ lean: jest.fn().mockResolvedValue({ + _id: 'wake', agentName: 'inbox', instanceId: 'default', podId: 'pod-a', + type: 'chat.mention', status: 'delivered', payload: {}, + }) }) + .mockReturnValueOnce({ lean: jest.fn().mockResolvedValue({ + _id: 'digest', podId: 'pod-a', type: 'message.posted', deliveryMode: 'digest', + status: 'delivered', payload: { messageId: '42' }, + }) }); + readFoldedDigest.mockResolvedValueOnce({ unreadCount: 3, lastEventIds: ['older-1'], revision: 4 }); + AgentEvent.updateOne.mockResolvedValueOnce({ modifiedCount: 1 }); + const page = await AgentEventService.listInboxPage({ agentName: 'inbox', podId: 'pod-a' }); + expect(page.events.map((event) => event._id)).toEqual(['wake', 'digest']); + expect(page.inboxCount).toBe(2); + expect(page.events[0].payload.digestRollup).toEqual({ unreadCount: 3, lastEventIds: ['older-1'] }); + expect(page.events[1].payload.digestRollup).toBeUndefined(); + expect(AgentEvent.find).toHaveBeenNthCalledWith(3, expect.objectContaining({ + podId: 'pod-a', deliveryMode: { $ne: 'digest' }, + })); + expect(AgentEvent.find).toHaveBeenNthCalledWith(4, expect.objectContaining({ + podId: 'pod-a', deliveryMode: 'digest', + })); + }); + + test('a sibling winning the wake leaves its digest companion unclaimed', async () => { + const AgentMemory = require('../../../models/AgentMemory'); + const query = (rows) => ({ + sort: () => ({ limit: () => ({ select: () => ({ lean: jest.fn().mockResolvedValue(rows) }) }) }), + }); + AgentEvent.find + .mockReturnValueOnce(query([{ _id: 'wake', podId: 'pod-a' }])) + .mockReturnValueOnce(query([{ _id: 'wake' }])) + .mockReturnValueOnce(query([{ _id: 'digest' }])); + AgentMemory.findOne.mockReturnValue({ + select: () => ({ lean: jest.fn().mockResolvedValue({ revision: 1, lastSeenRevision: 0 }) }), + }); + AgentEvent.findOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); + const page = await AgentEventService.listInboxPage({ agentName: 'inbox', podId: 'pod-a' }); + expect(page.events).toEqual([]); + expect(AgentEvent.findOneAndUpdate).toHaveBeenCalledTimes(1); + }); + test('listInboxPage preserves its pre-claim count when a sibling wins every candidate', async () => { const query = (rows) => ({ sort: () => ({ @@ -436,10 +507,16 @@ describe('AgentEventService', () => { agentName: 'inbox', instanceId: 'default', podIds: ['pod-a', 'pod-b'], limit: 10, }); - expect(AgentEvent.find).toHaveBeenCalledTimes(1); - expect(AgentEvent.find).toHaveBeenCalledWith(expect.objectContaining({ + expect(AgentEvent.find).toHaveBeenCalledTimes(3); + expect(AgentEvent.find).toHaveBeenNthCalledWith(1, expect.objectContaining({ podId: { $in: ['pod-a', 'pod-b'] }, })); + expect(AgentEvent.find).toHaveBeenNthCalledWith(2, expect.objectContaining({ + podId: 'pod-a', deliveryMode: 'digest', + })); + expect(AgentEvent.find).toHaveBeenNthCalledWith(3, expect.objectContaining({ + podId: 'pod-b', deliveryMode: 'digest', + })); expect(events.map((event) => event.podId)).toEqual(['pod-a', 'pod-b']); }); @@ -511,6 +588,7 @@ describe('AgentEventService', () => { test('acknowledge: status-gated delivered → acked + bumps lastSeenRevision via $max', async () => { const AgentMemory = require('../../../models/AgentMemory'); + const { settleFoldedDigest } = require('../../../services/agentDigestFoldService'); AgentIdentityService.getAgentTypeConfig.mockReturnValue({ runtime: 'moltbot' }); AgentEvent.findOneAndUpdate.mockResolvedValue({ _id: 'evt-1', @@ -522,9 +600,10 @@ describe('AgentEventService', () => { status: 'acked', attempts: 1, memoryRevisionAtDelivery: 7, + digestRollupRevision: 4, }); - await AgentEventService.acknowledge('evt-1', 'pixel', 'default', { outcome: 'no_action', reason: 'nothing-to-do' }); + await AgentEventService.acknowledge('evt-1', 'pixel', 'default', { outcome: 'no_action' }); expect(AgentEvent.findOneAndUpdate).toHaveBeenCalledWith( expect.objectContaining({ @@ -543,6 +622,9 @@ describe('AgentEventService', () => { { agentName: 'pixel', instanceId: 'default' }, { $max: { lastSeenRevision: 7 } }, ); + expect(settleFoldedDigest).toHaveBeenCalledWith( + { agentName: 'pixel', instanceId: 'default', podId: 'pod-1' }, 4, + ); }); test('acknowledge: dup ack returns null without bumping lastSeenRevision', async () => { diff --git a/backend/__tests__/unit/services/agentMentionService.addressingIsNotThreadScoped.test.js b/backend/__tests__/unit/services/agentMentionService.addressingIsNotThreadScoped.test.js index 1362164d77..cb9eff9c75 100644 --- a/backend/__tests__/unit/services/agentMentionService.addressingIsNotThreadScoped.test.js +++ b/backend/__tests__/unit/services/agentMentionService.addressingIsNotThreadScoped.test.js @@ -50,6 +50,9 @@ jest.mock('../../../models/pg/ThreadUserState', () => ({ jest.mock('../../../services/threadWakeScopeService', () => ({ narrowToThread: jest.fn(async (_rootId, targets) => targets), })); +jest.mock('../../../services/messageDeliveryModeService', () => ({ + addressedMessageTargets: jest.fn(async () => new Set()), +})); const AgentMentionService = require('../../../services/agentMentionService'); const AgentEventService = require('../../../services/agentEventService'); @@ -158,9 +161,9 @@ describe('the addressing fan-out never consults thread scope', () => { }); expect(mentions()).toHaveLength(1); - expect(narrowToThread).toHaveBeenCalledTimes(1); + expect(narrowToThread).not.toHaveBeenCalled(); // The mentioned seat is excluded from its own ambient companion, so the - // narrowing cannot double-deliver — it can only remove. + // empty companion does not need a scoping read. expect(wakes()).toHaveLength(0); }); diff --git a/backend/__tests__/unit/services/agentMentionService.adjacentReply.test.js b/backend/__tests__/unit/services/agentMentionService.adjacentReply.test.js index e69b88de15..a80c4a63ef 100644 --- a/backend/__tests__/unit/services/agentMentionService.adjacentReply.test.js +++ b/backend/__tests__/unit/services/agentMentionService.adjacentReply.test.js @@ -11,7 +11,7 @@ * within 30 minutes, reaches exactly that agent as one chat.mention carrying * the replies-to-you frame; * - a second person, an older or human or system previous message, a thread - * reply, or a DM-shaped pod: nothing; + * reply, or a DM-shaped pod: no adjacency address; * - an @mention or a reply edge is addressing already, and adjacency adds * nothing on top; * - a wake-on agent already hears every message, so it is not delivered twice; @@ -162,7 +162,7 @@ describe('adjacency is addressing in a one-person room (ADR-018 D6.4)', () => { test('a second person in the room: nothing, because the line may be for them', async () => { setup({ members: [OWNER, SECOND_HUMAN, SEAT_A_BOT] }); const res = await send('what do you both think?'); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(calls('chat.mention')).toHaveLength(0); expect(res.implicit).toEqual([]); }); @@ -197,11 +197,10 @@ describe('adjacency is addressing in a one-person room (ADR-018 D6.4)', () => { ])('a previous message that is %s: nothing', async (_label, previous) => { setup({ previous }); await send('hello?'); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(calls('chat.mention')).toHaveLength(0); // Each of these is refused from the previous row alone: the only User // lookup is the sender's own, never the previous author's. expect(User.findById).toHaveBeenCalledTimes(1); - expect(Pod.findById).not.toHaveBeenCalled(); }); test('a previous message by a person whose row carries agent metadata: nothing, isBot decides', async () => { @@ -210,20 +209,19 @@ describe('adjacency is addressing in a one-person room (ADR-018 D6.4)', () => { previous: { user_id: PERSON_WITH_STALE_BOT_METADATA, created_at: new Date(NOW.getTime() - 5000), message_type: 'text' }, }); await send('hello?'); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); - expect(Pod.findById).not.toHaveBeenCalled(); + expect(calls('chat.mention')).toHaveLength(0); }); test('a previous message from an agent no longer installed here: nothing', async () => { setup({ installations: [install('other-seat')] }); await send('hello?'); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(calls('chat.mention')).toHaveLength(0); }); test('a thread reply is not adjacent to the room', async () => { setup(); await send('in the thread', { message: { thread_root_id: 79400 } }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(calls('chat.mention')).toHaveLength(0); expect(PGMessage.findPreviousTopLevel).not.toHaveBeenCalled(); }); diff --git a/backend/__tests__/unit/services/agentMentionService.humansAreNotWoken.test.js b/backend/__tests__/unit/services/agentMentionService.humansAreNotWoken.test.js index 061c95fa6d..ed450d74e6 100644 --- a/backend/__tests__/unit/services/agentMentionService.humansAreNotWoken.test.js +++ b/backend/__tests__/unit/services/agentMentionService.humansAreNotWoken.test.js @@ -13,9 +13,10 @@ * * Two mechanisms, stated separately because they can break separately: * - * 1. A handle that resolves to a human enqueues no `AgentEvent` of any type. - * `enqueueMentions` has no human delivery branch at all — the handle is - * filtered into `humanMentionHandles` and never reaches an enqueue. The + * 1. A handle that resolves to a human enqueues no addressed `AgentEvent`. + * Ambient activity is now received as a digest by installed agents, but + * the human handle must not turn that receipt into a model wake. The + * handle is filtered into `humanMentionHandles` and never reaches an address. The * realistic future edit is TASK-070b (should a bare name route?): an * implementer who answers "yes, and push it" makes the cue a lie taught on * every wake, fleet-wide, and nothing in the suite objects. @@ -48,6 +49,13 @@ jest.mock('../../../models/pg/Message', () => ({ findById: jest.fn(async () => n jest.mock('../../../models/pg/ThreadUserState', () => ({ followByParticipation: jest.fn().mockResolvedValue(true), })); +jest.mock('../../../services/threadWakeScopeService', () => ({ + threadAuthorIds: jest.fn().mockResolvedValue(new Set()), + narrowToThread: jest.fn().mockResolvedValue([]), +})); +jest.mock('../../../services/messageClaimService', () => ({ + liveClaimantsInThread: jest.fn().mockResolvedValue(new Set()), +})); const AgentMentionService = require('../../../services/agentMentionService'); const AgentEventService = require('../../../services/agentEventService'); @@ -104,12 +112,13 @@ beforeEach(() => { }); describe('a human handle is not a delivery target', () => { - test('@handle for a human enqueues no event of any kind', async () => { + test('@handle for a human is ambient receipt, never an addressed wake', async () => { await send({ id: 'm-1', content: 'can you decide this @sam' }); - // Not "no chat.mention" — no event at all. A future human-push branch is - // as likely to invent a type as to reuse this one. - expect(enqueued()).toHaveLength(0); + expect(enqueued()).toEqual([expect.objectContaining({ + agentName: 'seat-a', type: 'message.posted', deliveryMode: 'digest', + })]); + expect(mentions()).toHaveLength(0); }); test('CONTROL: the same sentence addressed to an installed agent DOES enqueue', async () => { @@ -149,7 +158,7 @@ describe('the thread-follow half is guarded by threadRootId', () => { expect(ThreadUserState.followByParticipation).toHaveBeenCalledWith(101, 'user-sam', 'pod-1'); }); - test('a follow is not a wake — the threaded case still enqueues no event', async () => { + test('a human follow does not add the agent to the threaded wake set', async () => { // Both halves of the cue's ceiling in one assertion: the handle bought a // pull-surface row, and nothing was pushed. await send({ diff --git a/backend/__tests__/unit/services/agentMentionService.test.js b/backend/__tests__/unit/services/agentMentionService.test.js index 9b93df3edf..bd9ae5335f 100644 --- a/backend/__tests__/unit/services/agentMentionService.test.js +++ b/backend/__tests__/unit/services/agentMentionService.test.js @@ -257,7 +257,8 @@ describe('AgentMentionService', () => { username: 'theo', }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls.map(([args]) => args) + .filter((args) => args.deliveryMode !== 'digest')).toHaveLength(0); expect(User.findById).toHaveBeenCalledTimes(1); expect(result.implicit).toEqual([]); }); @@ -391,7 +392,8 @@ describe('AgentMentionService', () => { username: 'alice', }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls.map(([args]) => args) + .filter((args) => args.deliveryMode !== 'digest')).toHaveLength(0); expect(result.implicit).toEqual([]); }); @@ -1444,7 +1446,7 @@ describe('AgentMentionService', () => { expect(res.skipped).toEqual([]); }); - test('bot -> bot OVER threshold is suppressed (loop dampened)', async () => { + test('bot -> bot OVER threshold does not wake, but remains received as digest', async () => { setupTargetAgent(); asBotSender(); // 4 prior mentions > MENTION_LOOP_MAX (3) → treat as a loop. @@ -1457,7 +1459,10 @@ describe('AgentMentionService', () => { username: 'theo', }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue).toHaveBeenCalledTimes(1); + expect(AgentEventService.enqueue).toHaveBeenCalledWith(expect.objectContaining({ + deliveryMode: 'digest', type: 'message.posted', + })); expect(res.enqueued).toEqual([]); expect(res.skipped).toEqual(['openclaw:loop-dampened']); }); @@ -2000,7 +2005,8 @@ describe('AgentMentionService', () => { }); expect(res.enqueued).toEqual([]); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls.map(([args]) => args) + .filter((args) => args.deliveryMode !== 'digest')).toHaveLength(0); }); test('the budget is shared, so alternating surfaces cannot each sit at half of it', async () => { diff --git a/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js b/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js index d7c33eb030..3dacf4ddd4 100644 --- a/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js +++ b/backend/__tests__/unit/services/agentMentionService.threadScoping.test.js @@ -29,6 +29,9 @@ jest.mock('../../../services/threadWakeScopeService', () => ({ narrowToThread: jest.fn(async (_root, targets) => targets), effectiveFollowerIds: jest.fn(async () => new Set()), })); +jest.mock('../../../services/messageDeliveryModeService', () => ({ + addressedMessageTargets: jest.fn(async () => new Set()), +})); const AgentMentionService = require('../../../services/agentMentionService'); const AgentEventService = require('../../../services/agentEventService'); @@ -38,6 +41,7 @@ const Pod = require('../../../models/Pod'); const User = require('../../../models/User'); const AgentEvent = require('../../../models/AgentEvent'); const { narrowToThread } = require('../../../services/threadWakeScopeService'); +const { addressedMessageTargets } = require('../../../services/messageDeliveryModeService'); const ThreadUserState = require('../../../models/pg/ThreadUserState'); const PGMessage = require('../../../models/pg/Message'); @@ -83,6 +87,7 @@ const send = (message, extra = {}) => AgentMentionService.enqueueMentions({ beforeEach(() => { jest.clearAllMocks(); narrowToThread.mockImplementation(async (_root, targets) => targets); + addressedMessageTargets.mockResolvedValue(new Set()); AgentInstallation.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([ optIn('seat-a', 'human-installer-a'), optIn('seat-b', 'human-installer-b'), @@ -195,6 +200,8 @@ describe('the hook fires only for threaded messages', () => { test('the camelCase spelling works too — callers pass either', async () => { await send({ id: 'm3', content: 'in a thread', threadRootId: 101 }); expect(narrowToThread).toHaveBeenCalledTimes(1); + expect(byType('message.posted')).not.toHaveLength(0); + expect(byType('message.posted').every((event) => event.payload.threadRootId === 101)).toBe(true); }); }); @@ -211,6 +218,14 @@ describe('scoping narrows delivery, and only delivery', () => { expect(byType('message.posted')).toHaveLength(0); }); + test('a current thread author stays addressed when ambient scoping drops everyone', async () => { + addressedMessageTargets.mockResolvedValue(new Set(['seat-a:default'])); + narrowToThread.mockImplementation(async () => []); + await send({ id: 'm5-addressed', content: 'threaded', thread_root_id: 101 }); + expect(byType('message.posted').map((event) => [event.agentName, event.deliveryMode])) + .toEqual([['seat-a', 'wake']]); + }); + test('CONTROL: the same message unthreaded still wakes both', async () => { // Without this, the two tests above pass from a pipeline that wakes nobody. narrowToThread.mockImplementation(async () => []); diff --git a/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js b/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js index eb2542cb08..cc5bb847ac 100644 --- a/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js +++ b/backend/__tests__/unit/services/agentMentionService.wakeOnMessage.test.js @@ -3,7 +3,7 @@ * exercised through enqueueMentions, the module boundary). * * The contract under test: - * - opt-in only: `config.wakeOnMessage.enabled === true`, default OFF + * - opt-in wakes; default-off installations receive a pending digest * - unrouted messages wake opt-ins as `message.posted` with the inline frame * - mention-enqueued targets are NOT double-delivered * - an agent never wakes on its own post @@ -69,6 +69,7 @@ const AgentProfile = require('../../../models/AgentProfile'); const Pod = require('../../../models/Pod'); const User = require('../../../models/User'); const AgentEvent = require('../../../models/AgentEvent'); +const messageDeliveryModeService = require('../../../services/messageDeliveryModeService'); const install = (agentName, { optIn = false, instanceId = 'default' } = {}) => ({ agentName, @@ -174,10 +175,12 @@ describe('wake-on-message (ADR-018 D8)', () => { }); expect(res.woken).toEqual(['seat-a', 'seat-b']); - expect(wakeCalls().map((call) => call.agentName)).toEqual(['seat-a', 'seat-b']); + expect(wakeCalls().filter((call) => call.deliveryMode === 'wake').map((call) => call.agentName)) + .toEqual(['seat-a', 'seat-b']); + expect(wakeCalls().find((call) => call.agentName === 'seat-c').deliveryMode).toBe('digest'); }); - test('default is OFF: installs without the flag are never woken', async () => { + test('default is OFF: installs without the flag get digest delivery without a wake', async () => { mockInstallations([install('seat-a'), install('seat-b')]); const res = await AgentMentionService.enqueueMentions({ @@ -188,11 +191,29 @@ describe('wake-on-message (ADR-018 D8)', () => { }); expect(res.woken).toEqual([]); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); - // No pod-shape lookup: with no opt-in there is no wake fan-out, and D6.4 - // adjacency looks at the pod only when the previous message is a recent - // agent post (here there is none). - expect(Pod.findById).not.toHaveBeenCalled(); + expect(wakeCalls().map((call) => [call.agentName, call.deliveryMode])) + .toEqual([['seat-a', 'digest'], ['seat-b', 'digest']]); + expect(wakeCalls()[0].payload.content).toBe('nobody asked anyone anything'); + }); + + test('an addressed lookup outage keeps ambient receipt without waking default-off seats', async () => { + mockInstallations([install('seat-a'), install('seat-b', { optIn: true })]); + const lookup = jest.spyOn(messageDeliveryModeService, 'addressedMessageTargets') + .mockRejectedValue(new Error('lookup unavailable')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const res = await AgentMentionService.enqueueMentions({ + podId: 'pod-1', + message: { content: 'TASK-414 update', id: 'msg-outage' }, + userId: 'user-1', username: 'alice', + }); + expect(res.woken).toEqual(['seat-b']); + expect(wakeCalls().map((call) => [call.agentName, call.deliveryMode])) + .toEqual([['seat-a', 'digest'], ['seat-b', 'wake']]); + } finally { + lookup.mockRestore(); + warn.mockRestore(); + } }); test('a mention-enqueued target is not double-delivered; other opt-ins still wake', async () => { @@ -246,10 +267,10 @@ describe('wake-on-message (ADR-018 D8)', () => { }); expect(res.woken).toEqual([]); - expect(wakeCalls()).toHaveLength(0); + expect(wakeCalls()).toEqual([expect.objectContaining({ agentName: 'seat-b', deliveryMode: 'digest' })]); // The dampener counted the WAKE event type, not mentions. expect(AgentEvent.countDocuments).toHaveBeenCalledWith( - expect.objectContaining({ type: 'message.posted', agentName: 'seat-b' }), + expect.objectContaining({ type: 'message.posted', agentName: 'seat-b', deliveryMode: { $ne: 'digest' } }), ); }); @@ -291,6 +312,7 @@ describe('wake-on-message (ADR-018 D8)', () => { podId: 'pod-1', message: { content: 'Scout says welcome', id: 'msg-h2' }, userId: 'bot-1', username: 'scout', }); expect(res.woken).toEqual(['seat-b']); + expect(wakeCalls().find((call) => call.agentName === 'sam-claude').deliveryMode).toBe('digest'); }); test('an unknown sender is not a known human, so it doesn\'t wake either', async () => { @@ -300,6 +322,7 @@ describe('wake-on-message (ADR-018 D8)', () => { podId: 'pod-1', message: { content: 'who am I', id: 'msg-h3' }, userId: 'ghost', username: 'ghost', }); expect(res.woken).toEqual([]); + expect(wakeCalls().find((call) => call.agentName === 'sam-claude').deliveryMode).toBe('digest'); }); }); diff --git a/backend/__tests__/unit/services/messageClaimService.liveClaims.pgmem.test.js b/backend/__tests__/unit/services/messageClaimService.liveClaims.pgmem.test.js index 8782984339..cda1f60dc5 100644 --- a/backend/__tests__/unit/services/messageClaimService.liveClaims.pgmem.test.js +++ b/backend/__tests__/unit/services/messageClaimService.liveClaims.pgmem.test.js @@ -36,6 +36,9 @@ const lease = (messageId, { ); beforeAll(async () => { + await realPool.query(`CREATE TABLE messages ( + id INTEGER PRIMARY KEY, pod_id TEXT NOT NULL, thread_root_id INTEGER + )`); await realPool.query(`CREATE TABLE message_claims ( message_id TEXT PRIMARY KEY, pod_id TEXT NOT NULL, @@ -49,6 +52,7 @@ beforeAll(async () => { beforeEach(async () => { await realPool.query('DELETE FROM message_claims'); + await realPool.query('DELETE FROM messages'); }); const held = () => MessageClaimService.liveClaimsHeldBy({ podId: POD, agentName: 'Scout', instanceId: 'u1' }); @@ -83,3 +87,14 @@ describe('liveClaimsHeldBy', () => { await expect(held()).resolves.toEqual([]); }); }); + +describe('liveClaimantsInThread', () => { + test('finds only active claims on this thread in this pod', async () => { + await realPool.query('INSERT INTO messages (id, pod_id, thread_root_id) VALUES (900, $1, NULL), (901, $1, 900), (902, $1, 900), (903, $2, 900)', [POD, OTHER_POD]); + await lease('901', { claimedBy: 'Scout' }); + await lease('902', { claimedBy: 'flint', state: 'completed' }); + await lease('903', { claimedBy: 'other', podId: OTHER_POD }); + await expect(MessageClaimService.liveClaimantsInThread(POD, 900)) + .resolves.toEqual(new Set(['scout:u1'])); + }); +}); diff --git a/backend/__tests__/unit/services/messageDeliveryModeService.test.js b/backend/__tests__/unit/services/messageDeliveryModeService.test.js new file mode 100644 index 0000000000..ad82c0c035 --- /dev/null +++ b/backend/__tests__/unit/services/messageDeliveryModeService.test.js @@ -0,0 +1,52 @@ +jest.mock('../../../models/Task', () => ({ find: jest.fn() })); +jest.mock('../../../services/threadWakeScopeService', () => ({ threadAuthorIds: jest.fn() })); +jest.mock('../../../services/messageClaimService', () => ({ liveClaimantsInThread: jest.fn() })); + +const Task = require('../../../models/Task'); +const { threadAuthorIds } = require('../../../services/threadWakeScopeService'); +const MessageClaimService = require('../../../services/messageClaimService'); +const { + addressedMessageTargets, messageReferenceMatchers, +} = require('../../../services/messageDeliveryModeService'); + +const targets = [{ agentName: 'flint', instanceId: 'default' }, { agentName: 'sable', instanceId: 'default' }]; +const botUserIds = new Map([['flint:default', 'bot-flint'], ['sable:default', 'bot-sable']]); +const classify = (content, threadRootId = null) => addressedMessageTargets({ + podId: 'pod-a', content, threadRootId, targets, botUserIds, +}); + +describe('ambient addressed delivery', () => { + beforeEach(() => { + jest.clearAllMocks(); + Task.find.mockReturnValue({ select: () => ({ lean: jest.fn().mockResolvedValue([]) }) }); + threadAuthorIds.mockResolvedValue(new Set()); + MessageClaimService.liveClaimantsInThread.mockResolvedValue(new Set()); + }); + + test('thread authors and live claimants wake only their own seat', async () => { + threadAuthorIds.mockResolvedValue(new Set(['bot-flint'])); + MessageClaimService.liveClaimantsInThread.mockResolvedValue(new Set(['sable:default'])); + expect(await classify('ambient', 42)).toEqual(new Set(['flint:default', 'sable:default'])); + expect(MessageClaimService.liveClaimantsInThread).toHaveBeenCalledWith('pod-a', 42); + }); + + test('an exact TASK reference addresses its assignee and a live claim holder', async () => { + Task.find.mockReturnValue({ select: () => ({ lean: jest.fn().mockResolvedValue([ + { taskId: 'TASK-414', assignee: 'flint', status: 'done' }, + { taskId: 'TASK-415', claimedBy: 'bot-sable', status: 'claimed', claimExpiresAt: new Date(Date.now() + 60000) }, + ]) }) }); + expect(await classify('TASK-414 and TASK-415')).toEqual(new Set(['flint:default', 'sable:default'])); + expect(await classify('TASK-4140')).toEqual(new Set()); + expect(messageReferenceMatchers.taskRefs('xTASK-414y')).toEqual([]); + }); + + test('a PR number matches the task prUrl exactly, not a numeric prefix', async () => { + Task.find.mockReturnValue({ select: () => ({ lean: jest.fn().mockResolvedValue([ + { taskId: 'TASK-414', assignee: 'flint', status: 'pending', prUrl: 'https://github.com/Team-Commonly/commonly/pull/2534' }, + ]) }) }); + expect(await classify('review #2534')).toEqual(new Set(['flint:default'])); + expect(await classify('review #25345')).toEqual(new Set()); + expect(messageReferenceMatchers.prNumber('https://github.com/Team-Commonly/commonly/pull/25345')) + .toBe('25345'); + }); +}); diff --git a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js index 5a5d57f324..867107354b 100644 --- a/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js +++ b/backend/__tests__/unit/services/schedulerService.heartbeatLease.test.js @@ -11,6 +11,10 @@ const { } = require('../../utils/testUtils'); const { AgentInstallation } = require('../../../models/AgentRegistry'); const SchedulerHeartbeatLease = require('../../../models/SchedulerHeartbeatLease'); +const AgentEvent = require('../../../models/AgentEvent'); +const AgentDigestState = require('../../../models/AgentDigestState'); +const AgentEventService = require('../../../services/agentEventService'); +const mongoose = require('mongoose'); const schedulerServiceInstance = require('../../../services/schedulerService'); const SchedulerService = schedulerServiceInstance.constructor; @@ -96,4 +100,74 @@ describe('SchedulerService automatic heartbeat dispatch lease', () => { }); expect(AgentInstallation.find).toHaveBeenCalledWith({ status: 'active' }); }); + + it('only wakes a pod with digest unread for 60 minutes and no recent or pending wake', async () => { + const podId = new mongoose.Types.ObjectId(); + const scope = { agentName: 'flint', instanceId: 'default', podId }; + const enqueue = jest.spyOn(AgentEventService, 'enqueue').mockResolvedValue({}); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + await AgentEvent.collection.insertOne({ ...scope, type: 'message.posted', status: 'pending', + deliveryMode: 'digest', attempts: 0, createdAt: new Date(NOW.getTime() - 61 * 60000) }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + await AgentInstallation.collection.insertOne({ ...scope, status: 'active', + version: '1.0.0', installedBy: new mongoose.Types.ObjectId() }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(1); + expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ + ...scope, type: 'heartbeat', payload: expect.objectContaining({ trigger: 'unread-digest' }), + })); + + enqueue.mockClear(); + await AgentEvent.collection.insertOne({ ...scope, type: 'chat.mention', status: 'pending', + deliveryMode: 'wake', attempts: 0, createdAt: NOW }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + await AgentEvent.deleteMany({ ...scope, type: 'chat.mention' }); + await AgentEvent.collection.insertOne({ ...scope, type: 'chat.mention', status: 'acked', + deliveryMode: 'wake', attempts: 1, deliveredAt: new Date(NOW.getTime() - 30 * 60000), createdAt: NOW }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + expect(enqueue).not.toHaveBeenCalled(); + }); + + it('waits an hour after a wake fails, even if it was delivered earlier', async () => { + const scope = { agentName: 'flint', instanceId: 'default', podId: new mongoose.Types.ObjectId() }; + const enqueue = jest.spyOn(AgentEventService, 'enqueue').mockResolvedValue({}); + const old = new Date(NOW.getTime() - 61 * 60000); + await AgentInstallation.collection.insertOne({ ...scope, status: 'active', + version: '1.0.0', installedBy: new mongoose.Types.ObjectId() }); + await AgentEvent.collection.insertOne({ ...scope, type: 'message.posted', status: 'pending', + deliveryMode: 'digest', attempts: 0, createdAt: old }); + const failed = await AgentEvent.collection.insertOne({ ...scope, type: 'heartbeat', status: 'failed', + deliveryMode: 'wake', attempts: 1, createdAt: old, deliveredAt: old, updatedAt: NOW }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + expect(enqueue).not.toHaveBeenCalled(); + await AgentEvent.collection.updateOne({ _id: failed.insertedId }, { $set: { updatedAt: old } }); + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(1); + }); + + it('also wakes for unread digest folded past full-event retention', async () => { + const podId = new mongoose.Types.ObjectId(); + const scope = { agentName: 'flint', instanceId: 'default', podId }; + const enqueue = jest.spyOn(AgentEventService, 'enqueue').mockResolvedValue({}); + await AgentInstallation.collection.insertOne({ ...scope, status: 'active', + version: '1.0.0', installedBy: new mongoose.Types.ObjectId() }); + await AgentDigestState.collection.insertOne({ ...scope, unreadCount: 2, revision: 2, + lastEventIds: ['old-1', 'old-2'], + unreadSince: new Date(NOW.getTime() - 61 * 60000), updatedAt: NOW }); + + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(1); + expect(enqueue).toHaveBeenCalledTimes(1); + }); + + it('does not wake an installation whose autonomy is disabled', async () => { + const podId = new mongoose.Types.ObjectId(); + const scope = { agentName: 'flint', instanceId: 'default', podId }; + const enqueue = jest.spyOn(AgentEventService, 'enqueue').mockResolvedValue({}); + await AgentInstallation.collection.insertOne({ ...scope, status: 'active', + version: '1.0.0', installedBy: new mongoose.Types.ObjectId(), + config: { autonomy: { enabled: false } } }); + await AgentEvent.collection.insertOne({ ...scope, type: 'message.posted', status: 'pending', + deliveryMode: 'digest', attempts: 0, createdAt: new Date(NOW.getTime() - 61 * 60000) }); + + expect(await SchedulerService.dispatchUnreadDigestHeartbeats(NOW)).toBe(0); + expect(enqueue).not.toHaveBeenCalled(); + }); }); diff --git a/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js b/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js index 062df0f29e..6569a9a9bd 100644 --- a/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js +++ b/backend/__tests__/unit/services/taskEventService.notifyPodAgents.test.js @@ -167,7 +167,8 @@ describe('notifyPodAgents', () => { updatedAt: new Date('2026-08-20T20:00:00.000Z'), }), 'updated', { userId: HUMAN_ID, isAgent: false }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue).toHaveBeenCalledTimes(1); + expect(AgentEventService.enqueue).toHaveBeenCalledWith(expect.objectContaining({ deliveryMode: 'digest' })); expect(mockTaskUpdateOne).not.toHaveBeenCalled(); }); @@ -311,7 +312,8 @@ describe('notifyPodAgents', () => { userId: AGENT_ID, isAgent: true, agentName: 'scout', instanceId: 'u0da521ab41', }); - const woken = AgentEventService.enqueue.mock.calls.map((c) => c[0].instanceId); + const woken = AgentEventService.enqueue.mock.calls + .filter(([event]) => event.deliveryMode !== 'digest').map(([event]) => event.instanceId); expect(woken).toEqual(['ucc4035c51b']); }); @@ -324,7 +326,8 @@ describe('notifyPodAgents', () => { await notifyPodAgents(POD_ID, task({ assignee: 'openclaw' }), 'updated', { userId: HUMAN_ID, isAgent: false }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls).toHaveLength(2); + expect(AgentEventService.enqueue.mock.calls.every(([event]) => event.deliveryMode === 'digest')).toBe(true); }); it('still wakes an agent when the HUMAN who installed it edits the board', async () => { @@ -377,8 +380,10 @@ describe('notifyPodAgents', () => { await notifyPodAgents(POD_ID, task({ assignee: 'commonly-bot' }), 'created', { userId: HUMAN_ID, isAgent: false }); - const woken = AgentEventService.enqueue.mock.calls.map((c) => c[0].agentName); + const woken = AgentEventService.enqueue.mock.calls + .filter(([event]) => event.deliveryMode !== 'digest').map(([event]) => event.agentName); expect(woken).toEqual(['commonly-bot']); + expect(AgentEventService.enqueue.mock.calls.filter(([event]) => event.deliveryMode === 'digest')).toHaveLength(2); }); it('resolves a claimed row through the bot principal, not its installer', async () => { @@ -390,7 +395,9 @@ describe('notifyPodAgents', () => { await notifyPodAgents(POD_ID, task({ assignee: null, claimedBy: AGENT_ID }), 'updated', { userId: HUMAN_ID, isAgent: false }); - expect(AgentEventService.enqueue.mock.calls.map(([event]) => event.agentName)).toEqual(['sprint-impl']); + expect(AgentEventService.enqueue.mock.calls + .filter(([event]) => event.deliveryMode !== 'digest').map(([event]) => event.agentName)) + .toEqual(['sprint-impl']); }); it('does not treat an ambiguous default claim key as every default seat', async () => { @@ -399,7 +406,7 @@ describe('notifyPodAgents', () => { await notifyPodAgents(POD_ID, task({ assignee: null, claimedBy: 'default' }), 'updated', { userId: HUMAN_ID, isAgent: false }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls.every(([event]) => event.deliveryMode === 'digest')).toBe(true); }); it('does not treat an ambiguous default assignee as every default seat', async () => { @@ -408,7 +415,7 @@ describe('notifyPodAgents', () => { await notifyPodAgents(POD_ID, task({ assignee: 'default' }), 'updated', { userId: HUMAN_ID, isAgent: false }); - expect(AgentEventService.enqueue).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls.every(([event]) => event.deliveryMode === 'digest')).toBe(true); }); it('wakes a claimed dependent row when its exact dependency clears', async () => { @@ -419,7 +426,34 @@ describe('notifyPodAgents', () => { { userId: HUMAN_ID, isAgent: false }, { status: 'claimed' }); expect(Task.find.mock.calls[0][0]).toMatchObject({ podId: POD_ID, dep: 'TASK-042' }); - expect(AgentEventService.enqueue.mock.calls.map(([event]) => event.agentName)).toEqual(['sprint-impl']); + expect(AgentEventService.enqueue.mock.calls + .filter(([event]) => event.deliveryMode !== 'digest').map(([event]) => event.agentName)) + .toEqual(['sprint-impl']); + }); + + it('wakes a claimed dependent row through the bot principal', async () => { + mockInstalls([install('scout', HUMAN_ID), install('sprint-impl', HUMAN_ID)]); + Task.find.mockReturnValueOnce({ lean: async () => [{ assignee: null, claimedBy: AGENT_ID }] }); + User.findById.mockReturnValue({ select: () => ({ lean: async () => ({ + isBot: true, botMetadata: { agentName: 'sprint-impl', instanceId: 'default' }, + }) }) }); + + await notifyPodAgents(POD_ID, task({ assignee: null, status: 'done' }), 'updated', + { userId: HUMAN_ID, isAgent: false }, { status: 'claimed' }); + + const bySeat = Object.fromEntries(AgentEventService.enqueue.mock.calls.map(([arg]) => [arg.agentName, arg])); + expect(bySeat['sprint-impl'].deliveryMode).toBeUndefined(); + expect(bySeat.scout.deliveryMode).toBe('digest'); + }); + + it('does not repeat dependency wakes for a later note on an already done row', async () => { + mockInstalls([install('sprint-impl', HUMAN_ID)]); + + await notifyPodAgents(POD_ID, task({ assignee: null, status: 'done' }), 'updated', + { userId: HUMAN_ID, isAgent: false }); + + expect(Task.find).not.toHaveBeenCalled(); + expect(AgentEventService.enqueue.mock.calls[0][0].deliveryMode).toBe('digest'); }); it('stays quiet on delete, where the wake would spend a turn finding nothing', async () => { diff --git a/backend/models/AgentDigestState.ts b/backend/models/AgentDigestState.ts new file mode 100644 index 0000000000..9bf5db97eb --- /dev/null +++ b/backend/models/AgentDigestState.ts @@ -0,0 +1,36 @@ +import mongoose, { Document, Model, Schema, Types } from 'mongoose'; + +/** Unread ambient events folded out of the full AgentEvent retention window. */ +export interface IAgentDigestState extends Document { + agentName: string; + instanceId: string; + podId: Types.ObjectId; + unreadCount: number; + revision: number; + lastEventIds: string[]; + conversations: Array<{ label: string; count: number }>; + otherCount: number; + unreadSince?: Date | null; + foldedThroughId?: Types.ObjectId | null; + updatedAt: Date; +} + +const schema = new Schema({ + agentName: { type: String, required: true, lowercase: true }, + instanceId: { type: String, required: true, default: 'default' }, + podId: { type: Schema.Types.ObjectId, required: true, ref: 'Pod' }, + unreadCount: { type: Number, required: true, default: 0 }, + revision: { type: Number, required: true, default: 0 }, + lastEventIds: { type: [String], default: [] }, + conversations: { type: [{ label: String, count: Number, _id: false }], default: [] }, + otherCount: { type: Number, default: 0 }, + unreadSince: { type: Date, default: null }, + foldedThroughId: { type: Schema.Types.ObjectId, default: null }, +}, { timestamps: true }); + +schema.index({ agentName: 1, instanceId: 1, podId: 1 }, { unique: true }); +schema.index({ unreadCount: 1, unreadSince: 1 }); + +const AgentDigestState: Model = mongoose.model('AgentDigestState', schema); +export default AgentDigestState; +module.exports = exports["default"]; Object.assign(module.exports, exports); diff --git a/backend/models/AgentEvent.ts b/backend/models/AgentEvent.ts index da41d76246..cd5a399353 100644 --- a/backend/models/AgentEvent.ts +++ b/backend/models/AgentEvent.ts @@ -6,7 +6,8 @@ import mongoose, { Document, Model, Schema, Types } from 'mongoose'; // delivered → failed (on delivery-side terminal error; rare) // Existing rows without an `'acked'` value continue to read as `'delivered'` // indefinitely; the ack-gate below short-circuits if status is already `'acked'`. -export type AgentEventStatus = 'pending' | 'delivered' | 'acked' | 'failed'; +export type AgentEventStatus = 'pending' | 'folding' | 'delivered' | 'acked' | 'failed'; +export type AgentEventDeliveryMode = 'wake' | 'digest'; export type DeliveryOutcome = 'acknowledged' | 'posted' | 'no_action' | 'skipped' | 'error'; // ADR-003 Phase 4: cross-agent ask/respond payloads. The `type` field on @@ -92,6 +93,8 @@ export interface IAgentEvent extends Document { type: string; payload?: unknown; status: AgentEventStatus; + // Independent of `delivery`, which records the eventual ack outcome. + deliveryMode: AgentEventDeliveryMode; attempts: number; deliveredAt?: Date; error?: string; @@ -107,6 +110,12 @@ export interface IAgentEvent extends Document { // superseded child's late ack fail instead of terminating the new child's // delivery. `null` for events claimed before D6 shipped. deliveryNonce?: string | null; + // Snapshot of the folded digest state included in this wake. Ack clears it + // only if no newer fold changed the state while the turn was running. + digestRollupRevision?: number | null; + // Raw ambient rows presented inside a legacy wake or webhook delivery. + // They settle only when that wake succeeds. + digestEventIds?: Types.ObjectId[]; createdAt: Date; updatedAt: Date; } @@ -118,12 +127,15 @@ const AgentEventSchema = new Schema( podId: { type: Schema.Types.ObjectId, ref: 'Pod', required: true, index: true }, type: { type: String, required: true }, payload: Schema.Types.Mixed, - status: { type: String, enum: ['pending', 'delivered', 'acked', 'failed'], default: 'pending' }, + status: { type: String, enum: ['pending', 'folding', 'delivered', 'acked', 'failed'], default: 'pending' }, + deliveryMode: { type: String, enum: ['wake', 'digest'], default: 'wake' }, attempts: { type: Number, default: 0 }, deliveredAt: Date, error: String, memoryRevisionAtDelivery: { type: Number, default: null }, deliveryNonce: { type: String, default: null }, + digestRollupRevision: { type: Number, default: null }, + digestEventIds: [{ type: Schema.Types.ObjectId, ref: 'AgentEvent' }], delivery: { outcome: { type: String, @@ -139,6 +151,8 @@ const AgentEventSchema = new Schema( ); AgentEventSchema.index({ agentName: 1, instanceId: 1, status: 1, createdAt: 1 }); +AgentEventSchema.index({ status: 1, deliveryMode: 1, createdAt: 1 }); +AgentEventSchema.index({ agentName: 1, instanceId: 1, podId: 1, status: 1, deliveryMode: 1, createdAt: 1 }); // ADR-024 D3 reads one pod-scoped inbox page after finding the global head. // This keeps that bounded page and its true-count query from scanning pending // work in every other pod for the same agent installation. diff --git a/backend/services/agentDigestFoldService.ts b/backend/services/agentDigestFoldService.ts new file mode 100644 index 0000000000..00ff1e3b8e --- /dev/null +++ b/backend/services/agentDigestFoldService.ts @@ -0,0 +1,186 @@ +import AgentEvent from '../models/AgentEvent'; +import AgentDigestState from '../models/AgentDigestState'; + +const FOLD_BATCH_SIZE = 100; +export const FOLD_TIME_BUDGET_MS = 10_000; +const LAST_IDS_LIMIT = 8; +const CONVERSATION_LIMIT = 8; + +const conversationLabel = (payload?: Record): string => { + const thread = Number(payload?.threadRootId); + if (Number.isInteger(thread) && thread > 0) return `thread ${thread}`; + const task = payload?.taskId; + return typeof task === 'string' && /^TASK-\d+$/.test(task) ? task : 'pod conversation'; +}; + +type FoldCandidate = { + _id: unknown; + agentName: string; + instanceId?: string; + podId: unknown; + status: string; + createdAt?: Date; + payload?: Record; +}; + +/** + * Compact expired ambient events without losing unread state. Marking an + * event `folding` first excludes a concurrent poll claim. The monotonic + * per-seat cursor makes a crash after the state update, but before deletion, + * idempotent on the next GC pass. Each pass processes IDs in ascending order. + */ +export async function foldExpiredDigests(threshold: Date, now: () => number = Date.now): Promise { + const deadline = now() + FOLD_TIME_BUDGET_MS; + let folded = 0; + let afterId: unknown; + const blockedScopes = new Map(); + while (now() < deadline) { + // Page past already inspected IDs, including scopes deferred by another + // worker. Exclude blocked scopes so a large busy scope cannot fill every + // subsequent page and starve healthy scopes within this run's budget. + // eslint-disable-next-line no-await-in-loop + const candidates = await AgentEvent.find({ + deliveryMode: 'digest', status: { $in: ['pending', 'folding', 'delivered', 'failed'] }, + createdAt: { $lt: threshold }, + ...(afterId ? { _id: { $gt: afterId } } : {}), + ...(blockedScopes.size ? { $nor: [...blockedScopes.values()] } : {}), + }).sort({ _id: 1 }).limit(FOLD_BATCH_SIZE).lean() as FoldCandidate[]; + if (!candidates.length) break; + for (const candidate of candidates) { + if (now() >= deadline) return folded; + afterId = candidate._id; + const id = candidate._id; + const scope = { + agentName: candidate.agentName, + instanceId: candidate.instanceId || 'default', + podId: candidate.podId, + }; + const scopeKey = `${scope.agentName}:${scope.instanceId}:${scope.podId}`; + if (blockedScopes.has(scopeKey)) continue; + // createdAt and ObjectId order can diverge for an imported row. Never + // advance this scope's cursor beyond an earlier unread ID, even if that + // earlier row has not reached the retention horizon yet. + // eslint-disable-next-line no-await-in-loop + const olderUnread = await AgentEvent.exists({ + ...scope, deliveryMode: 'digest', status: { $in: ['pending', 'folding', 'delivered', 'failed'] }, + _id: { $lt: id }, + }); + if (olderUnread) { + blockedScopes.set(scopeKey, scope); + continue; + } + if (candidate.status !== 'folding') { + // eslint-disable-next-line no-await-in-loop + const claimed = await AgentEvent.findOneAndUpdate( + { _id: id, status: candidate.status, deliveryMode: 'digest' }, + { $set: { status: 'folding' } }, + { new: true }, + ); + if (!claimed) { + // Another GC worker may be folding this oldest row. If we advanced to + // a later ID first, its cursor would make that older row disappear + // without ever increasing unreadCount. + blockedScopes.set(scopeKey, scope); + continue; + } + } + const idString = String(id); + const label = conversationLabel(candidate.payload); + let recorded = false; + for (let attempt = 0; attempt < 5 && !recorded; attempt += 1) { + // eslint-disable-next-line no-await-in-loop + const state = await AgentDigestState.findOne(scope) + .select('foldedThroughId unreadCount conversations otherCount').lean(); + const cursor = state?.foldedThroughId; + if (cursor && String(cursor) >= idString) { + recorded = true; + break; + } + if (!state) { + try { + // eslint-disable-next-line no-await-in-loop + await AgentDigestState.create({ + ...scope, unreadCount: 1, revision: 1, lastEventIds: [idString], + conversations: [{ label, count: 1 }], otherCount: 0, + unreadSince: candidate.createdAt || new Date(), foldedThroughId: id, + }); + recorded = true; + } catch (err) { + if ((err as { code?: number }).code !== 11000) throw err; + } + } else { + const conversations = [...(state.conversations || [])].map((item) => ({ + label: item.label, count: item.count, + })); + let otherCount = Math.max(state.otherCount || 0, + state.unreadCount - conversations.reduce((sum, item) => sum + item.count, 0)); + const existing = conversations.find((item) => item.label === label); + if (existing) existing.count += 1; + else if (conversations.length < CONVERSATION_LIMIT) conversations.push({ label, count: 1 }); + else otherCount += 1; + // eslint-disable-next-line no-await-in-loop + const result = await AgentDigestState.updateOne( + { ...scope, foldedThroughId: cursor ?? null, unreadCount: state.unreadCount }, + { + $inc: { unreadCount: 1, revision: 1 }, + $set: { + foldedThroughId: id, + conversations, + otherCount, + ...(state.unreadCount === 0 ? { unreadSince: candidate.createdAt || new Date() } : {}), + }, + $push: { lastEventIds: { $each: [idString], $slice: -LAST_IDS_LIMIT } }, + }, + ); + recorded = result.modifiedCount === 1; + } + } + if (!recorded) { + // Keep this scope's unread order and retry it next pass. A busy seat + // must not prevent unrelated seats from compacting their old events. + blockedScopes.set(scopeKey, scope); + console.warn(`[agent-digest] fold state contention for ${idString}; deferring scope`); + continue; + } + // If this delete fails, the next pass sees `folding` and the cursor avoids + // incrementing a second time. A delivered/acked event never enters here. + // eslint-disable-next-line no-await-in-loop + const result = await AgentEvent.deleteOne({ _id: id, status: 'folding', deliveryMode: 'digest' }); + folded += result.deletedCount || 0; + } + if (candidates.length < FOLD_BATCH_SIZE) break; + } + return folded; +} + +export async function readFoldedDigest(scope: { + agentName: string; instanceId: string; podId: unknown; +}): Promise<{ unreadCount: number; lastEventIds: string[]; revision: number; + conversations: Array<{ label: string; count: number }>; otherCount: number } | null> { + const state = await AgentDigestState.findOne({ ...scope, unreadCount: { $gt: 0 } }) + .select('unreadCount lastEventIds revision conversations otherCount').lean(); + if (!state) return null; + return { + unreadCount: state.unreadCount, + lastEventIds: state.lastEventIds, + revision: state.revision, + conversations: state.conversations || [], + otherCount: Math.max(state.otherCount || 0, + state.unreadCount - (state.conversations || []).reduce((sum, item) => sum + item.count, 0)), + }; +} + +export async function settleFoldedDigest( + scope: { agentName: string; instanceId: string; podId: unknown }, + revision: number, +): Promise { + // A newer fold increments revision. In that race leave the old count in place: + // the next turn may see some IDs twice, but never loses an unseen event. + await AgentDigestState.updateOne( + { ...scope, revision }, + { $set: { unreadCount: 0, lastEventIds: [], conversations: [], otherCount: 0, unreadSince: null }, + $inc: { revision: 1 } }, + ); +} + +export default { foldExpiredDigests, readFoldedDigest, settleFoldedDigest }; diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 68c9290ca0..6181686d62 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; import { Types } from 'mongoose'; import Task from '../models/Task'; +import { foldExpiredDigests, readFoldedDigest, settleFoldedDigest } from './agentDigestFoldService'; import { postToPublicAddress } from './outboundUrlGuard'; import { hostedRunsNative, IN_PROCESS_RUNTIME_FILTER } from './hostedEngine'; @@ -51,6 +52,44 @@ const mintDeliveryNonce = (): string => crypto.randomBytes(16).toString('hex'); const OBJECT_ID_HEX = /^[a-f\d]{24}$/i; +// A wake advertises unread conversations, not message text. The seat can +// choose which threads to pull with commonly_get_messages after it wakes. +const digestConversationFrame = ( + rows: Array<{ payload?: Record }>, + olderCount = 0, + olderConversations: Array<{ label: string; count: number }> = [], +): string => { + const counts = new Map(); + let knownOlder = 0; + for (const conversation of olderConversations) { + if (!conversation.label || !Number.isInteger(conversation.count) || conversation.count < 1) continue; + counts.set(conversation.label, (counts.get(conversation.label) || 0) + conversation.count); + knownOlder += conversation.count; + } + if (olderCount > knownOlder) counts.set('older pod activity', olderCount - knownOlder); + const seen = new Set(); + for (const row of rows) { + const payload = row.payload || {}; + const sourceId = payload.messageId != null ? `message:${payload.messageId}` + : (typeof payload.taskId === 'string' + ? `task:${payload.taskId}:${String(payload.taskKind || '')}:${String(payload.content || '')}` : null); + if (sourceId && seen.has(sourceId)) continue; + if (sourceId) seen.add(sourceId); + const thread = payload.threadRootId; + const task = payload.taskId; + const label = typeof thread === 'number' && Number.isInteger(thread) && thread > 0 + ? `thread ${thread}` + : (typeof task === 'string' && /^TASK-\d+$/.test(task) ? task : 'pod conversation'); + counts.set(label, (counts.get(label) || 0) + 1); + } + const lines = [...counts].slice(0, 8).map(([label, count]) => `- ${label}: ${count} unread`); + const omitted = Math.max(0, counts.size - 8); + if (omitted) lines.push(`- ${omitted} more conversations omitted`); + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + return `[Since you last looked: ${total} ambient update${total === 1 ? '' : 's'} in ${counts.size} conversation${counts.size === 1 ? '' : 's'}; context only, no reply needed.]\n` + + `${lines.join('\n')}\nPull relevant messages with commonly_get_messages. On resume, read oldest unread first; at most 8 conversations, 5 messages each, 20 messages total.`; +}; + // TASK-264: how many stuck in-process events one sweep re-runs, and how long a // 'queued'/'running' AgentRun may sit before it counts as abandoned. const STUCK_REDISPATCH_BATCH = 50; @@ -122,10 +161,13 @@ interface EventDoc { createdAt?: Date; payload?: Record; status?: string; + deliveryMode?: 'wake' | 'digest'; attempts?: number; delivery?: DeliveryMeta; memoryRevisionAtDelivery?: number | null; deliveryNonce?: string | null; + digestRollupRevision?: number | null; + digestEventIds?: unknown[]; } interface InstallationDoc { @@ -164,6 +206,7 @@ interface GarbageCollectResult { deletedDelivered: number; deletedFailed: number; totalDeleted: number; + foldedDigest?: number; deliveredRetentionHours: number; failedRetentionHours: number; requeuedDelivered?: number; @@ -219,6 +262,7 @@ interface EnqueueOptions { type: string; payload?: Record; instanceId?: string; + deliveryMode?: 'wake' | 'digest'; } interface ListOptions { @@ -255,6 +299,12 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even const { webhookUrl, webhookSecret } = runtimeConfig as { webhookUrl?: string; webhookSecret?: string }; if (!webhookUrl) return; + // Webhook drivers never poll the inbox. Carry ambient context on their next + // waking POST, then settle it only after the endpoint accepts that POST. + await AgentEventService.attachLegacyDigest(event).catch((err: Error) => { + console.warn('[webhook] digest attachment failed:', err.message); + }); + const payload = JSON.stringify({ _id: event._id, type: event.type, @@ -291,7 +341,9 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even let response: Record = {}; try { response = JSON.parse(res.text) as Record; } catch (_) { /* no body is fine */ } - const { outcome = 'acknowledged', content } = response as { outcome?: string; content?: string }; + const { outcome = 'acknowledged', content, reason } = response as { + outcome?: string; content?: string; reason?: string; + }; if (outcome === 'posted' && content) { try { @@ -324,6 +376,11 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even 'delivery.outcome': outcome, 'delivery.updatedAt': new Date(), }); + if (outcome === 'posted' || outcome === 'acknowledged' || (outcome === 'no_action' && !reason)) { + await AgentEventService.settleLegacyDigest(event).catch((err: Error) => { + console.warn('[webhook] digest settle failed:', err.message); + }); + } } catch (err) { if ((err as Error).name === 'AbortError') { console.warn(`[webhook] ${event.agentName} ${webhookUrl} timed out after 10s`); @@ -714,6 +771,7 @@ class AgentEventService { `id=${details.eventId || 'n/a'}`, ]; if (details.trigger) parts.push(`trigger=${details.trigger}`); + if (details.deliveryMode) parts.push(`deliveryMode=${details.deliveryMode}`); if (details.status) parts.push(`status=${details.status}`); if (typeof details.attempts === 'number') parts.push(`attempts=${details.attempts}`); if (details.error) parts.push(`error="${details.error}"`); @@ -858,7 +916,15 @@ class AgentEventService { // and 'acked' is the new terminal state. Both age out on the same retention // schedule — once an event is past the `delivered` threshold it's stale // whether the agent ever explicitly acked or not. - const [pendingResult, deliveredResult, failedResult] = await Promise.all([ + let foldedDigest = 0; + try { + foldedDigest = await foldExpiredDigests(deliveredThreshold); + } catch (err) { + // The fold is fail-closed: its `folding` rows remain retryable and the + // pending delete below excludes digest. Other event retention can run. + console.warn('[agent-event] digest fold failed:', (err as Error).message); + } + const [pendingResult, ackedResult, deliveredResult, failedResult] = await Promise.all([ // #993: this used to run on a 30-minute `stalePendingThreshold`, which put // it AHEAD of the retry lifecycle rather than after it. The requeue pass // above sets `status` but not `createdAt`, so an event it had just rescued @@ -880,20 +946,32 @@ class AgentEventService { // (routes/admin/agentEvents.ts), where "pending for over 30 minutes" is a // useful thing to look at — and is now actionable, because seeing it no // longer means the row is about to be destroyed. - AgentEvent.deleteMany({ status: 'pending', createdAt: { $lt: deliveredThreshold } }), - AgentEvent.deleteMany({ status: { $in: ['delivered', 'acked'] }, createdAt: { $lt: deliveredThreshold } }), - AgentEvent.deleteMany({ status: 'failed', createdAt: { $lt: failedThreshold } }), + // Digest rows are unread state. Retiring them without folding into a + // durable per-seat summary would silently erase the notification. + AgentEvent.deleteMany({ + status: 'pending', deliveryMode: { $ne: 'digest' }, createdAt: { $lt: deliveredThreshold }, + }), + AgentEvent.deleteMany({ + status: 'acked', createdAt: { $lt: deliveredThreshold }, + }), + AgentEvent.deleteMany({ + status: 'delivered', deliveryMode: { $ne: 'digest' }, createdAt: { $lt: deliveredThreshold }, + }), + AgentEvent.deleteMany({ + status: 'failed', deliveryMode: { $ne: 'digest' }, createdAt: { $lt: failedThreshold }, + }), ]) as Array<{ deletedCount?: number }>; const deletedPending = pendingResult?.deletedCount || 0; - const deletedDelivered = deliveredResult?.deletedCount || 0; + const deletedDelivered = (ackedResult?.deletedCount || 0) + (deliveredResult?.deletedCount || 0); const deletedFailed = failedResult?.deletedCount || 0; return { deletedPending, deletedDelivered, deletedFailed, - totalDeleted: deletedPending + deletedDelivered + deletedFailed, + totalDeleted: deletedPending + deletedDelivered + deletedFailed + foldedDigest, + foldedDigest, deliveredRetentionHours: Math.max(deliveredRetentionHours, 1), failedRetentionHours: Math.max(failedRetentionHours, 1), requeuedDelivered, @@ -924,6 +1002,7 @@ class AgentEventService { const AgentRun = require('../models/AgentRun'); const stuck = await AgentEvent.find({ status: 'delivered', + deliveryMode: { $ne: 'digest' }, deliveredAt: { $lt: threshold }, $or: [{ attempts: { $lt: attemptCap } }, { attempts: { $exists: false } }], }).sort({ deliveredAt: 1 }).limit(STUCK_REDISPATCH_BATCH).lean() as EventDoc[]; @@ -1029,7 +1108,7 @@ class AgentEventService { const targetFilter = { $or: targets }; const ceiling = new Date(now.getTime() - STRANDED_PENDING_MAX_AGE_MS); const closedResult = await AgentEvent.updateMany( - { status: 'pending', createdAt: { $lt: ceiling }, ...targetFilter }, + { status: 'pending', deliveryMode: { $ne: 'digest' }, createdAt: { $lt: ceiling }, ...targetFilter }, { $set: { status: 'failed', @@ -1040,6 +1119,7 @@ class AgentEventService { ) as { modifiedCount?: number }; const stranded = await AgentEvent.find({ status: 'pending', + deliveryMode: { $ne: 'digest' }, createdAt: { $lt: new Date(now.getTime() - STRANDED_PENDING_MIN_AGE_MS), $gte: ceiling, @@ -1325,11 +1405,92 @@ class AgentEventService { return installationRuntimeType === 'native' || hostedRunsNative(installationDoc) ? installationDoc : null; } - /** - * Run a claimed in-process event and settle it on the outcome: success acks, - * failure records. Fire-and-forget, so neither enqueue nor the restart sweep - * (TASK-264) ever blocks on a turn. - */ + /** Persist the folded digest snapshot carried by this waking delivery. */ + static async attachFoldedDigest(event: EventDoc): Promise<{ + unreadCount: number; lastEventIds: string[]; + conversations: Array<{ label: string; count: number }>; + } | null> { + const scope = { + agentName: String(event.agentName || '').toLowerCase(), + instanceId: String(event.instanceId || 'default'), + podId: event.podId, + }; + if (!scope.agentName || !scope.podId || !event._id) return null; + const state = await readFoldedDigest(scope); + if (!state) return null; + // A retry may show the same rollup. Only the winning ack with this exact + // state snapshot clears it; a newer fold must remain unread. + const updated = await AgentEvent.updateOne( + { _id: event._id, status: 'delivered', deliveryMode: { $ne: 'digest' }, + ...(event.deliveryNonce ? { deliveryNonce: event.deliveryNonce } : {}) }, + { $set: { digestRollupRevision: state.revision } }, + ); + if (!updated.modifiedCount) return null; + return { unreadCount: state.unreadCount, lastEventIds: state.lastEventIds, + conversations: state.conversations }; + } + + /** Add one bounded ambient frame to a wake consumed by an older poller or webhook. */ + static async attachLegacyDigest(event: EventDoc): Promise { + if (!event._id || !event.podId || !event.agentName || event.deliveryMode === 'digest') return; + const scope = { + agentName: String(event.agentName).toLowerCase(), + instanceId: String(event.instanceId || 'default'), + podId: event.podId, + }; + const [raw, rollup] = await Promise.all([ + AgentEvent.find({ ...scope, status: 'pending', deliveryMode: 'digest' }) + .sort({ createdAt: 1 }).limit(8).lean() as Promise, + readFoldedDigest(scope), + ]); + if (!raw.length && !rollup?.unreadCount) return; + const ids = raw.map((row) => row._id); + const saved = await AgentEvent.updateOne( + { + _id: event._id, + status: { $in: ['pending', 'delivered'] }, + deliveryMode: { $ne: 'digest' }, + ...(event.deliveryNonce ? { deliveryNonce: event.deliveryNonce } : {}), + }, + { $set: { + digestEventIds: ids, + digestRollupRevision: rollup?.revision ?? null, + } }, + ); + if (!saved.matchedCount) return; + event.digestEventIds = ids; + event.digestRollupRevision = rollup?.revision ?? null; + const summary = digestConversationFrame(raw, rollup?.unreadCount || 0, rollup?.conversations); + const baseContent = [event.payload?.content, event.payload?.prompt, event.payload?.text] + .find((value): value is string => typeof value === 'string' && value.length > 0) || ''; + event.payload = { + ...(event.payload || {}), + // Existing drivers consume content; the additive field also lets a new + // driver render the frame separately without parsing the wake text. + content: baseContent ? `${baseContent}\n\n${summary}` : summary, + ...(typeof event.payload?.prompt === 'string' + ? { prompt: `${event.payload.prompt}\n\n${summary}` } : {}), + digestSummary: summary, + }; + } + + static async settleLegacyDigest(event: EventDoc): Promise { + if (!event.agentName || !event.podId) return; + const scope = { + agentName: String(event.agentName).toLowerCase(), + instanceId: String(event.instanceId || 'default'), + podId: event.podId, + }; + if (event.digestEventIds?.length) { + await AgentEvent.updateMany( + { ...scope, _id: { $in: event.digestEventIds }, status: 'pending', deliveryMode: 'digest' }, + { $set: { status: 'acked', deliveredAt: new Date() } }, + ); + } + if (event.digestRollupRevision) await settleFoldedDigest(scope, event.digestRollupRevision); + } + + /** Run a claimed native event and settle it without blocking enqueue. */ static dispatchInProcess( event: EventDoc, nativeInstallation: InstallationDoc, @@ -1355,27 +1516,69 @@ class AgentEventService { // stayed 'delivered' forever and was structurally unackable. Doing it // here rather than inside runAgent keeps the terminal transition next // to the non-terminal state that created it. - Promise.resolve(runAgent(nativeInstallation, { - type, - eventId: eventIdStr, - payload: event.payload, + const runWithDigest = async () => { + const folded = await this.attachFoldedDigest(event).catch((digestErr: Error) => { + console.warn('[native-runtime] folded digest lookup failed:', digestErr.message); + return null; + }); + let digest: EventDoc[] = []; + try { + digest = await AgentEvent.find({ + agentName: agentName.toLowerCase(), instanceId, podId: event.podId, + status: 'pending', deliveryMode: 'digest', + }).sort({ createdAt: 1 }).limit(8).lean() as EventDoc[]; + } catch (digestErr) { + console.warn('[native-runtime] digest lookup failed:', (digestErr as Error).message); + } + const baseContent = String(event.payload?.content || event.payload?.prompt || event.payload?.text || ''); + const summary = digest.length || folded?.unreadCount + ? digestConversationFrame(digest, folded?.unreadCount || 0, folded?.conversations) : null; + const payload = summary ? { + ...event.payload, + content: `${baseContent}\n\n${summary}`, + } : event.payload; + const result = await runAgent(nativeInstallation, { type, eventId: eventIdStr, payload }); + // Leave digest unread when the native turn fails. On a successful turn, + // mark only the rows actually included; older backlog remains pending. + const modelTurnRan = result?.status === 'succeeded' + && Boolean(result.runId) && Number(result.totalTurns) > 0; + if (modelTurnRan && digest.length) { + await AgentEvent.updateMany( + { _id: { $in: digest.map((item) => item._id) }, status: 'pending', deliveryMode: 'digest' }, + { $set: { status: 'acked' } }, + ).catch((digestErr: Error) => { + console.warn('[native-runtime] digest settle failed:', digestErr.message); + }); + } + return result; + }; + Promise.resolve(runWithDigest()).then( // Two-argument .then, deliberately, NOT .then().catch(): a chained // .catch would also catch a rejection thrown by the success handler, // so a failing acknowledge() would fall through and mark a run that // actually succeeded as 'failed'. The rejection handler here is scoped // to runAgent alone. Both terminal calls swallow their own errors so // this stays fire-and-forget for the caller. - })).then( - () => this.acknowledge( + (runResult: { status?: string; error?: string; runId?: string; totalTurns?: number }) => (runResult?.status === 'failed' + ? this.recordFailure( + eventIdStr, + agentName, + instanceId, + `native runtime failed: ${runResult.error || 'turn failed'}`, + nativeDeliveryId, + ).catch(() => undefined) + : this.acknowledge( eventIdStr, agentName, instanceId, - { outcome: 'acknowledged', reason: 'native-runtime-completed' }, + runResult?.runId && Number(runResult.totalTurns) > 0 + ? { outcome: 'acknowledged', reason: 'native-runtime-completed' } + : { outcome: 'no_action', reason: 'native-no-turn' }, nativeDeliveryId, 'native', ).catch((ackErr: Error) => { console.warn('[native-runtime] ack after successful run failed:', ackErr?.message || ackErr); - }), + })), (err: Error) => { console.error('[native-runtime] runAgent failed:', err?.message || err); // Terminal on failure too — a native event that errored must not @@ -1399,7 +1602,7 @@ class AgentEventService { } static async enqueue({ - agentName, podId, type, payload = {}, instanceId = 'default', + agentName, podId, type, payload = {}, instanceId = 'default', deliveryMode = 'wake', }: EnqueueOptions): Promise { if (!agentName || !podId || !type) { throw new Error('agentName, podId, and type are required'); @@ -1437,7 +1640,9 @@ class AgentEventService { (lookupErr as Error).message, ); } - const routedToNative = Boolean(nativeInstallation); + // A digest is data for a later turn, not a turn on its own. Native + // installations must leave it pending just like external installations. + const routedToNative = Boolean(nativeInstallation) && deliveryMode === 'wake'; const event = await AgentEvent.create({ agentName: agentName.toLowerCase(), @@ -1445,6 +1650,7 @@ class AgentEventService { podId, type, payload: eventPayload, + ...(deliveryMode === 'digest' ? { deliveryMode } : {}), // Native runs resolve in-process, so the event is claimed at creation // rather than polled. `attempts: 1` because this IS its one delivery — // the claim in list() never runs for native events, and a native event @@ -1477,13 +1683,14 @@ class AgentEventService { instanceId: event.instanceId || '', podId: String((event.podId as { toString?: () => string })?.toString?.() || ''), type: event.type || '', + deliveryMode: event.deliveryMode || 'wake', trigger: event.payload?.trigger, status: event.status, attempts: event.attempts, }); const wsService = getWebSocketService(); - if (wsService) { + if (wsService && deliveryMode === 'wake') { wsService.pushEvent({ _id: event._id, agentName: event.agentName, @@ -1509,10 +1716,13 @@ class AgentEventService { // if the gateway is dead." Typing_stop is still emitted when // AgentMessageService.postMessage runs.) - AgentInstallation.find({ - agentName: event.agentName, - instanceId, - podId, + // Query from the persisted event and coerce the identifier to a scalar + // before it reaches Mongo. Callers of enqueue may supply a runtime payload. + const webhookPodId = String(event.podId || ''); + if (deliveryMode === 'wake' && Types.ObjectId.isValid(webhookPodId)) AgentInstallation.find({ + agentName: String(event.agentName || ''), + instanceId: String(event.instanceId || 'default'), + podId: new Types.ObjectId(webhookPodId), status: 'active', }).lean().then((installations: InstallationDoc[]) => { for (const inst of installations) { @@ -1544,7 +1754,18 @@ class AgentEventService { // Legacy consumers intentionally replay across every pod in their access // scope. The one-pod partition is opt-in through listInboxPage(), for any // HTTP poller whose batch becomes one pod-contextual model turn. - return (await this.claimPage(options)).events; + const events = (await this.claimPage(options)).events; + const seenPods = new Set(); + for (const event of events) { + const podKey = String(event.podId || ''); + if (!podKey || seenPods.has(podKey)) continue; + seenPods.add(podKey); + // eslint-disable-next-line no-await-in-loop -- attach once per distinct pod + await this.attachLegacyDigest(event).catch((err: Error) => { + console.warn('[agent-event] legacy digest attachment failed:', err.message); + }); + } + return events; } // The HTTP poller needs the true pending count for its inbox header even @@ -1564,6 +1785,12 @@ class AgentEventService { // sanitizer; the String() casts are that sanitizer. const safeAgentName = String(agentName || '').toLowerCase(); const safeInstanceId = String(instanceId || 'default'); + // An explicit empty access set means the caller has no currently visible + // pods. It must not fall through to an identity-wide inbox query after an + // installation or pod membership was removed between enqueue and drain. + if (Array.isArray(podIds) && podIds.length === 0 && !podId) { + return { events: [], inboxCount: 0 }; + } const query: Record = { agentName: safeAgentName, instanceId: safeInstanceId, @@ -1594,7 +1821,7 @@ class AgentEventService { // behind a row no poller can process. Keep the caller's scope inside // the same `$and` so this exclusion only narrows access. const headQuery = { - $and: [query, { podId: { $exists: true, $ne: null } }], + $and: [query, { podId: { $exists: true, $ne: null } }, { deliveryMode: { $ne: 'digest' } }], }; const [head] = await AgentEvent.find(headQuery) .sort({ createdAt: 1 }) @@ -1614,11 +1841,28 @@ class AgentEventService { inboxCount = await AgentEvent.countDocuments(batchQuery); } - const candidates = await AgentEvent.find(batchQuery) + const wakeQuery = { ...batchQuery, deliveryMode: { $ne: 'digest' } }; + const wakeCandidates = await AgentEvent.find(wakeQuery) .sort({ createdAt: 1 }) .limit(limit) .select({ _id: 1 }) .lean() as Array<{ _id: unknown }>; + // Digest rows are only claimed alongside a wake in the same pod. + // A full wake page still needs to tell the driver about unread ambient + // context. Probe one digest on full pages and reserve its slot only when + // one exists; otherwise keep the entire wake page. + // At limit=1 the digest is a companion to the single wake, not another + // runnable item. Return the pair so even that page exposes unread context. + const digestSlots = limit - wakeCandidates.length || 1; + const digestCandidates = onePod && wakeCandidates.length > 0 && digestSlots > 0 + ? await AgentEvent.find({ ...batchQuery, deliveryMode: 'digest' }) + .sort({ createdAt: 1 }) + .limit(digestSlots) + .select({ _id: 1 }) + .lean() as Array<{ _id: unknown }> + : []; + if (limit > 1 && wakeCandidates.length === limit && digestCandidates.length > 0) wakeCandidates.pop(); + const candidates = [...wakeCandidates, ...digestCandidates]; // A sibling poller can claim every candidate after our pre-claim count. // The page is empty, but the count still describes what this reader saw @@ -1642,7 +1886,10 @@ class AgentEventService { const digestBundle = buildMemoryDigestBundle(memoryDoc || {}, lastSeenRevision); const claimed: EventDoc[] = []; - for (const candidate of candidates) { + for (const [index, candidate] of candidates.entries()) { + // Another poller may win every wake after our read. In that race the + // digest must remain pending instead of being claimed on its own. + if (onePod && index >= wakeCandidates.length && claimed.length === 0) break; // Coerce candidate _id to string before it lands in the query — Mongo // accepts both string and ObjectId, and `unknown`-typed values are the // CodeQL trip wire. The find() above scopes by agentName + instanceId @@ -1672,13 +1919,23 @@ class AgentEventService { // this delivery belongs to is the only one whose ack can land. deliveryNonce: mintDeliveryNonce(), }, - $inc: { attempts: 1 }, + // Digest is context for another turn. A refused wake can attach the + // same digest repeatedly without consuming it, so charging those + // attachments against the poison-event attempt cap would lose it. + ...(index < wakeCandidates.length ? { $inc: { attempts: 1 } } : {}), }, { new: true }, ).lean() as EventDoc | null; if (updated) claimed.push(updated); } + const firstWake = claimed.find((event) => event.deliveryMode !== 'digest'); + const foldedRollup = onePod && firstWake + ? await this.attachFoldedDigest(firstWake).catch((err: Error) => { + console.warn('[agent-event] folded digest attachment failed:', err.message); + return null; + }) + : null; const events = claimed.map((event) => { const messageId = event?.payload?.messageId; const basePayload = event?.payload || {}; @@ -1688,6 +1945,7 @@ class AgentEventService { const enrichedPayload: Record = { ...basePayload, ...digestBundle, + ...(foldedRollup && event._id === firstWake?._id ? { digestRollup: foldedRollup } : {}), // ADR-026 D6: the driver echoes this back on ack. Additive — a driver // that ignores it still acks successfully during the migration window // described on acknowledge(). @@ -1777,13 +2035,33 @@ class AgentEventService { { new: true }, ) as EventDoc | null; + const consumedDigest = !normalizedDelivery + || normalizedDelivery.outcome === 'posted' + || normalizedDelivery.outcome === 'acknowledged' + || (normalizedDelivery.outcome === 'no_action' && !normalizedDelivery.reason); + if (result && consumedDigest) { + if (result.digestEventIds?.length) { + await this.settleLegacyDigest(result).catch((err: Error) => { + console.warn('[agent-event] legacy digest settle failed:', err.message); + }); + } else if (result.digestRollupRevision) { + await settleFoldedDigest( + { agentName: safeAgentName, instanceId: safeInstanceId, podId: result.podId }, + result.digestRollupRevision, + ).catch((err: Error) => { + console.warn('[agent-event] folded digest settle failed:', err.message); + }); + } + } + // ADR-012 §3: bump lastSeenRevision exactly once per event via $max. // First-ack only: the status-gate above ensures result is non-null only // on the winning ack. memoryRevisionAtDelivery was captured at fetch // time (`list()`); this update brings the agent's read-checkpoint up // to that revision. $max makes it monotone — out-of-order acks across // events still converge correctly. - if (result && typeof result.memoryRevisionAtDelivery === 'number' && result.memoryRevisionAtDelivery > 0) { + if (result && consumedDigest + && typeof result.memoryRevisionAtDelivery === 'number' && result.memoryRevisionAtDelivery > 0) { try { await AgentMemory.updateOne( { agentName: safeAgentName, instanceId: safeInstanceId }, @@ -1805,6 +2083,7 @@ class AgentEventService { instanceId, podId: String((result?.podId as { toString?: () => string })?.toString?.() || ''), type: result?.type || '', + deliveryMode: result?.deliveryMode || 'wake', trigger: result?.payload?.trigger, status: result?.status || 'acked', attempts: result?.attempts, diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index 48710ee717..dbb1c6ed3a 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -1200,6 +1200,7 @@ const isWakeLoopDampened = async ( instanceId: target.instanceId || 'default', podId, type: 'message.posted', + deliveryMode: { $ne: 'digest' }, createdAt: { $gte: new Date(Date.now() - MENTION_LOOP_WINDOW_MS) }, }); if (count > MENTION_LOOP_MAX) { @@ -1217,7 +1218,7 @@ const isWakeLoopDampened = async ( /** `agentName:instanceId`, lowercased — the identity an install and a bot User row share. */ const installKey = (inst: { agentName?: unknown; instanceId?: unknown }): string => `${ - String(inst.agentName || '').toLowerCase()}:${String(inst.instanceId || 'default')}`; + String(inst.agentName || '').toLowerCase()}:${String(inst.instanceId || 'default').toLowerCase()}`; /** A thread root arrives from PG as either its numeric column or JSON spelling. */ const resolveThreadRootId = (message: EnqueueMentionsOptions['message']): number | null => { @@ -1346,14 +1347,13 @@ const followMentionedThreadUsers = async ( }; /** - * Fan a message out to the pod's wake-on-message opt-ins. Skips the sender's + * Fan a message out to the pod's agent inboxes. Skips the sender's * own identity (an agent never wakes on its own post), anything the mention * path already enqueued this message (a mention is the stronger cue — double * delivery would burn a second model turn on the same trigger), and DM-shaped * pods (enqueueDmEvent already routes every message there). * - * The zero-opt-in fast path costs nothing beyond the installations array the - * caller already loaded — no queries, no pod lookup. + * Unaddressed activity for seats without wake-on-message becomes digest work. */ const enqueueWakeOnMessage = async ({ podId, message, rawContent, userId, username, source, installations, sender, excludeKeys, authorFrame, resolvedBotUserIds, @@ -1373,27 +1373,21 @@ const enqueueWakeOnMessage = async ({ replyToMessageId?: string | null; }): Promise => { const woken: string[] = []; - let targets = (installations || []).filter(wakeOnMessageEnabled); + const senderKey = sender?.isBot + ? `${String(sender.botMetadata?.agentName || '').toLowerCase()}:${String(sender.botMetadata?.instanceId || 'default').toLowerCase()}` + : null; + let targets = (installations || []).filter((inst) => { + const key = installKey(inst); + return key !== senderKey && !excludeKeys?.has(key); + }); if (targets.length === 0) return woken; // Ambient-only thread scoping (W-T 3/4, #1045). A reply inside a thread is // AMBIENT: it reaches the thread's effective followers, not the room. // - // Applied HERE, to the already-computed opt-in list, and nowhere else. Two - // consequences that are the point rather than side effects: - // - it can only NARROW. A thread follower who never opted into - // wake-on-message still gets nothing, because threading is additive and - // must not start delivering to seats that opted into nothing. - // - it cannot touch addressing. NOT because this branch is unreachable on - // a routed message — it is reachable, via the second call site at the - // tail of enqueueMentions, which runs unconditionally so a routed - // message's ambient companion is scoped too. It cannot touch addressing - // because the chat.mention has ALREADY been enqueued by the time this - // runs, and the mentioned seat arrives here inside `excludeKeys`. A mute - // scopes ambient activity; it never suppresses being addressed. - // (An earlier version of this comment claimed the branch runs only when - // `!isRouted`, contradicting the call-site comment below it and giving a - // reader the right conclusion from a false mechanism.) + // Only non-addressed delivery is narrowed. A current thread author or + // claimant is addressed by this message; an explicit @mention was already + // routed and removed from this fan-out above. // // Keyed on the BOT's User row id, which is what thread_user_state.user_id // holds: the follow/mute routes are dualAuth, so an agent writing its own @@ -1422,16 +1416,36 @@ const enqueueWakeOnMessage = async ({ // narrowing and the reply-evidence stamp share this (a suite pins the // single User.find). let cachedBotUserIds: Map | undefined = resolvedBotUserIds; + const needsOwnershipIds = Boolean(threadRootId) || /\bTASK-\d+\b|#\d+\b/i.test(rawContent); + let addressedTargets: Set; + try { + if (needsOwnershipIds) cachedBotUserIds = cachedBotUserIds ?? await resolveBotUserIds(targets); + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { addressedMessageTargets } = require('./messageDeliveryModeService'); + addressedTargets = await addressedMessageTargets({ + podId, content: rawContent, threadRootId, targets, + botUserIds: cachedBotUserIds ?? new Map(), + }); + } catch (error) { + console.warn('[message-delivery] addressed lookup failed (ambient fallback):', (error as Error).message); + // Keep receiving the message. A lookup outage cannot justify waking every + // installed seat; explicit mentions have already taken their own path. + addressedTargets = new Set(); + cachedBotUserIds = cachedBotUserIds ?? new Map(); + } if (threadRootId) { // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { narrowToThread } = require('./threadWakeScopeService'); cachedBotUserIds = cachedBotUserIds ?? await resolveBotUserIds(targets); const botUserIds = cachedBotUserIds; - targets = await narrowToThread( + const addressed = targets.filter((inst) => addressedTargets.has(installKey(inst))); + const ambient = targets.filter((inst) => !addressedTargets.has(installKey(inst))); + const scopedAmbient = await narrowToThread( threadRootId, - targets, + ambient, (inst: Record) => botUserIds.get(installKey(inst)) ?? null, ); + targets = [...addressed, ...scopedAmbient]; if (targets.length === 0) return woken; } @@ -1451,10 +1465,6 @@ const enqueueWakeOnMessage = async ({ // one-line change instead of a hunt for a call site that lied. const collaborativePod = isCollaborativePod(podRow?.type, installations); - const senderKey = sender?.isBot - ? `${String(sender.botMetadata?.agentName || '').toLowerCase()}:${String(sender.botMetadata?.instanceId || 'default').toLowerCase()}` - : null; - // Reply-evidence flag (ADR-018 D6.3, interim). The #703 implicit-reply path // is gated on `sender.isBot === false`, so a BOT's reply to an agent's // message reaches its author as plain ambient activity — and the claim @@ -1470,7 +1480,7 @@ const enqueueWakeOnMessage = async ({ // seat+thread — is NOT implemented, so the dampener is the only loop bound. // Read the ADR before widening this: the decision is deliberately staged. const parentMessageId = String( - replyToMessageId || (message as { reply_to_message_id?: unknown })?.reply_to_message_id || threadRootId || '', + replyToMessageId || (message as { reply_to_message_id?: unknown })?.reply_to_message_id || '', ) || null; let parentAuthorUserId: string | null = null; if (parentMessageId) { @@ -1501,27 +1511,36 @@ const enqueueWakeOnMessage = async ({ const key = `${agentName}:${instanceId.toLowerCase()}`; if (key === senderKey) return; if (excludeKeys?.has(key)) return; - if (humanOnlyWake(inst) && sender?.isBot !== false) return; - if (senderKey && await isWakeLoopDampened({ agentName, instanceId }, podId)) return; + const addressed = addressedTargets.has(key); + let deliveryMode = addressed || (wakeOnMessageEnabled(inst) + && (!humanOnlyWake(inst) || sender?.isBot === false)) ? 'wake' : 'digest'; + // Loop controls suppress a model turn, not receipt. The update remains + // available in the next digest for this seat. + if (deliveryMode === 'wake' && senderKey + && await isWakeLoopDampened({ agentName, instanceId }, podId)) deliveryMode = 'digest'; const repliesToYou = !!(parentAuthorUserId && parentBotUserIds && parentBotUserIds.get(installKey(inst)) === parentAuthorUserId); try { - const built = buildContentForTarget( - podId, - rawContent, - 'message.posted', - agentName, - collaborativePod, - authorFrame, - ); + const built = deliveryMode === 'digest' + ? String(rawContent || '').replace(/\s+/g, ' ').slice(0, 200) + : buildContentForTarget( + podId, + rawContent, + 'message.posted', + agentName, + collaborativePod, + authorFrame, + ); await AgentEventService.enqueue({ agentName, instanceId, podId, type: 'message.posted', + deliveryMode, payload: { messageId: authorFrame.messageId, + ...(threadRootId ? { threadRootId } : {}), // Inline cue, not only metadata — the flag below is for the // wrapper's claim decision; the model reads the content. content: repliesToYou ? `${REPLIES_TO_YOU_FRAME}\n${built}` : built, @@ -1530,7 +1549,8 @@ const enqueueWakeOnMessage = async ({ source, messageType: message?.messageType || message?.message_type || 'text', createdAt: message?.createdAt || message?.created_at || new Date(), - wakeOnMessage: true, + wakeOnMessage: wakeOnMessageEnabled(inst), + ...(addressed ? { addressed: true } : {}), // Claim-decline handoff is deliberately human-only. This stamp is // the cross-layer proof the wrapper and kernel need; absent remains // non-human/unknown for old events rather than widening a bot loop. @@ -1538,7 +1558,7 @@ const enqueueWakeOnMessage = async ({ ...(repliesToYou ? { repliesToYourMessage: true } : {}), }, }); - woken.push(agentName); + if (deliveryMode === 'wake') woken.push(agentName); } catch (error) { console.warn('Failed to enqueue wake-on-message event:', (error as Error).message); } diff --git a/backend/services/messageClaimService.ts b/backend/services/messageClaimService.ts index 8e7c09413b..3389b5499a 100644 --- a/backend/services/messageClaimService.ts +++ b/backend/services/messageClaimService.ts @@ -612,6 +612,23 @@ class MessageClaimService { return res.rows.map((r: { message_id: unknown }) => String(r.message_id)); } + /** Current claimants on any message in this thread, scoped to this pod. */ + static async liveClaimantsInThread(podId: string, threadRootId: number): Promise> { + if (!podId || !Number.isSafeInteger(threadRootId) || threadRootId <= 0) return new Set(); + await ensureTable(); + const res = await pool.query( + `SELECT DISTINCT LOWER(c.claimed_by) AS agent_name, c.instance_id + FROM message_claims c + JOIN messages m ON m.id::text = c.message_id AND m.pod_id = c.pod_id + WHERE c.pod_id = $1 AND COALESCE(m.thread_root_id, m.id) = $2 + AND c.state = 'claimed' AND c.expires_at >= NOW()`, + [String(podId), threadRootId], + ); + return new Set(res.rows.map((row: { agent_name: string; instance_id: string }) => ( + `${row.agent_name}:${row.instance_id || 'default'}` + ))); + } + static async holder(messageId: string): Promise { await ensureTable(); const res = await pool.query( diff --git a/backend/services/messageDeliveryModeService.ts b/backend/services/messageDeliveryModeService.ts new file mode 100644 index 0000000000..f9b97f2f32 --- /dev/null +++ b/backend/services/messageDeliveryModeService.ts @@ -0,0 +1,84 @@ +/** Addressed cues for ambient messages. A match only counts for the seat that owns it. */ +/* eslint-disable @typescript-eslint/no-require-imports, global-require */ +const Task = require('../models/Task'); +const { threadAuthorIds } = require('./threadWakeScopeService'); +const MessageClaimService = require('./messageClaimService'); + +interface Target { + agentName?: unknown; + instanceId?: unknown; +} + +const key = (target: Target): string => `${String(target.agentName || '').toLowerCase()}:${String(target.instanceId || 'default').toLowerCase()}`; + +const taskRefs = (content: string): string[] => [...new Set( + [...content.matchAll(/(?:^|[^a-z0-9])TASK-(\d+)(?![a-z0-9])/gi)] + .map((match) => `TASK-${match[1]}`), +)]; + +const prRefs = (content: string): Set => new Set( + [...content.matchAll(/(?:^|[^a-z0-9])#(\d+)(?![a-z0-9])/gi)] + .map((match) => match[1]), +); + +const prNumber = (url: unknown): string | null => { + if (typeof url !== 'string') return null; + const match = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)(?:[/?#]|$)/i.exec(url); + return match?.[1] || null; +}; + +export async function addressedMessageTargets({ + podId, content, threadRootId, targets, botUserIds, +}: { + podId: string; + content: string; + threadRootId: number | null; + targets: Target[]; + botUserIds: Map; +}): Promise> { + const addressed = new Set(); + if (threadRootId) { + const [authors, claimants] = await Promise.all([ + threadAuthorIds(threadRootId) as Promise>, + MessageClaimService.liveClaimantsInThread(podId, threadRootId) as Promise>, + ]); + for (const target of targets) { + const targetKey = key(target); + if (authors.has(botUserIds.get(targetKey) || '') || claimants.has(targetKey)) { + addressed.add(targetKey); + } + } + } + + const ids = taskRefs(content); + const prs = prRefs(content); + if (ids.length === 0 && prs.size === 0) return addressed; + const selectors: Array> = []; + if (ids.length) selectors.push({ taskId: { $in: ids } }); + if (prs.size) selectors.push({ + prUrl: { $regex: `/pull/(?:${[...prs].join('|')})(?:[/?#]|$)` }, + }); + const rows = await Task.find({ + podId, + $or: selectors, + }).select('taskId assignee claimedBy claimExpiresAt status prUrl').lean() as Array<{ + taskId: string; assignee?: string | null; claimedBy?: string | null; + claimExpiresAt?: Date | null; status: string; prUrl?: string | null; + }>; + const now = Date.now(); + for (const row of rows) { + if (!ids.includes(row.taskId) && !prs.has(prNumber(row.prUrl) || '')) continue; + for (const target of targets) { + const targetKey = key(target); + const agentName = String(target.agentName || '').toLowerCase(); + const assignee = String(row.assignee || '').replace(/^@/, '').toLowerCase(); + const liveClaim = row.status === 'claimed' + && (!row.claimExpiresAt || new Date(row.claimExpiresAt).getTime() >= now) + && String(row.claimedBy || '') === (botUserIds.get(targetKey) || ''); + if (assignee === agentName || liveClaim) addressed.add(targetKey); + } + } + return addressed; +} + +export const messageReferenceMatchers = { taskRefs, prRefs, prNumber }; diff --git a/backend/services/schedulerService.ts b/backend/services/schedulerService.ts index b90bf8b011..a4cb69b2f0 100644 --- a/backend/services/schedulerService.ts +++ b/backend/services/schedulerService.ts @@ -20,6 +20,8 @@ const { AgentInstallation } = require('../models/AgentRegistry'); // eslint-disable-next-line global-require const AgentEvent = require('../models/AgentEvent'); // eslint-disable-next-line global-require +const AgentDigestState = require('../models/AgentDigestState'); +// eslint-disable-next-line global-require const SchedulerHeartbeatLease = require('../models/SchedulerHeartbeatLease'); // eslint-disable-next-line global-require const AgentEnsembleService = require('./agentEnsembleService'); @@ -1120,7 +1122,64 @@ class SchedulerService { } const result = await SchedulerService.dispatchAgentHeartbeats({ ...options, now }); - return { ...result, skippedByLease: false }; + const unreadWakes = await SchedulerService.dispatchUnreadDigestHeartbeats(now); + return { ...result, enqueued: result.enqueued + unreadWakes, skippedByLease: false }; + } + + /** One slow wake for a pod with unread digest, never for an empty inbox. */ + static async dispatchUnreadDigestHeartbeats(now: Date = new Date()): Promise { + const threshold = new Date(now.getTime() - 60 * 60 * 1000); + const rawGroups = await AgentEvent.aggregate([ + { $match: { status: 'pending', deliveryMode: 'digest', createdAt: { $lte: threshold } } }, + { $group: { + _id: { agentName: '$agentName', instanceId: '$instanceId', podId: '$podId' }, + } }, + ]) as Array<{ _id: { agentName: string; instanceId: string; podId: unknown } }>; + const foldedGroups = await AgentDigestState.find({ unreadCount: { $gt: 0 }, unreadSince: { $lte: threshold } }) + .select('agentName instanceId podId').lean() as Array<{ agentName: string; instanceId: string; podId: unknown }>; + const groups = [...new Map([...rawGroups.map((row) => row._id), ...foldedGroups] + .map((scope) => [`${scope.agentName}:${scope.instanceId}:${scope.podId}`, { _id: scope }])).values()]; + let enqueued = 0; + for (const row of groups) { + const scope = { + agentName: row._id.agentName, + instanceId: row._id.instanceId || 'default', + podId: row._id.podId, + }; + // A retired projection has no listener. Its old digest remains for GC, + // but must not create a new wake every hour. + // eslint-disable-next-line no-await-in-loop + const active = await AgentInstallation.exists({ + ...scope, status: 'active', 'config.autonomy.enabled': { $ne: false }, + }); + if (!active) continue; + // An existing wake will carry the digest. A recent completed wake says + // the seat has already had a turn inside this 60-minute window. + // eslint-disable-next-line no-await-in-loop + const [pendingWake, recentWake] = await Promise.all([ + AgentEvent.exists({ ...scope, status: 'pending', deliveryMode: { $ne: 'digest' } }), + AgentEvent.exists({ ...scope, deliveryMode: { $ne: 'digest' }, $or: [ + { status: { $in: ['delivered', 'acked'] }, deliveredAt: { $gte: threshold } }, + // AgentEvent timestamps update on every failed transition, including + // failures before delivery and failures after an older delivery. + { status: 'failed', updatedAt: { $gte: threshold } }, + ] }), + ]); + if (pendingWake || recentWake) continue; + // eslint-disable-next-line no-await-in-loop + await AgentEventService.enqueue({ + ...scope, + type: 'heartbeat', + payload: { + trigger: 'unread-digest', + podId: String(scope.podId), + generatedAt: now.toISOString(), + content: '[Unread pod activity is waiting. Read the digest in this inbox batch; respond only if useful.]', + }, + }); + enqueued += 1; + } + return enqueued; } static async dispatchAgentHeartbeats( diff --git a/backend/services/taskEventService.ts b/backend/services/taskEventService.ts index 2fa5f411d1..86292ec009 100644 --- a/backend/services/taskEventService.ts +++ b/backend/services/taskEventService.ts @@ -248,6 +248,7 @@ export async function notifyPodAgents( }).lean(); // Ownership addresses a seat directly, independent of ambient wake opt-in. + // Other seats still receive durable board context without a model turn. const installs = active || []; if (!installs.length) return; @@ -334,8 +335,18 @@ export async function notifyPodAgents( || seatHoldsClaim(install, previousHolder) || dependentRows.some((row) => seatMatchesAssignee(install, row.assignee)) || dependentHolders.some((holder) => seatHoldsClaim(install, holder)); - if (!ownRow) return null; try { + if (!ownRow) { + await AgentEventService.enqueue({ + agentName, instanceId, podId: normalizedPodId, type: 'message.posted', + deliveryMode: 'digest', + payload: { + podId: normalizedPodId, boardDigest: true, taskId, taskKind: kind, + content: `${taskId} ${kind}: ${title}`, + }, + }); + return true; + } // Both hosted engines answer chat.mention; they acknowledge a generic // message.posted without a turn. Other drivers use message.posted. const eventType = await usesHostedBoardWake(install, AgentInstallation) diff --git a/backend/services/threadWakeScopeService.ts b/backend/services/threadWakeScopeService.ts index d97754ef20..eab4ccd2b9 100644 --- a/backend/services/threadWakeScopeService.ts +++ b/backend/services/threadWakeScopeService.ts @@ -78,6 +78,15 @@ export async function effectiveFollowerIds(threadRootId: number): Promise String(r.user_id))); } +/** Authorship is an addressed cue; following alone is not. */ +export async function threadAuthorIds(threadRootId: number): Promise> { + const { rows } = await (pool as PgPool).query( + 'SELECT DISTINCT user_id FROM messages WHERE thread_root_id = $1 OR id = $1', + [threadRootId], + ); + return new Set(rows.map((row) => String(row.user_id))); +} + /** * Narrow an already-computed wake target list to the thread's effective * followers. Returns the input unchanged when the message is not threaded, so @@ -112,5 +121,5 @@ export async function narrowToThread( }); } -module.exports = { effectiveFollowerIds, narrowToThread }; +module.exports = { effectiveFollowerIds, threadAuthorIds, narrowToThread }; Object.assign(module.exports, exports); diff --git a/cli/__tests__/lib.test.mjs b/cli/__tests__/lib.test.mjs index d3daef68f4..740cde2fa3 100644 --- a/cli/__tests__/lib.test.mjs +++ b/cli/__tests__/lib.test.mjs @@ -538,6 +538,140 @@ describe('poller.js', () => { ); }); + test('keeps a digest-only page quiet and attaches its frame to the next wake', async () => { + const digest = { + ...makeEvent('digest-1'), deliveryMode: 'digest', + type: 'message.posted', payload: { + content: 'private ambient body', username: 'Ava', messageId: '42', threadRootId: 21, + deliveryId: 'digest-nonce', + }, + }; + const wake = { + ...makeEvent('wake-1'), deliveryMode: 'wake', + type: 'chat.mention', payload: { + content: '@my-agent please help', deliveryId: 'wake-nonce', + digestRollup: { unreadCount: 2, conversations: [{ label: 'TASK-7', count: 2 }] }, + }, + }; + const mockGet = jest.fn() + .mockResolvedValueOnce({ events: [digest] }) + .mockResolvedValueOnce({ events: [wake, digest] }) + .mockResolvedValue({ events: [] }); + const mockPost = jest.fn().mockResolvedValue({}); + const onEvent = jest.fn().mockResolvedValue({ outcome: 'acknowledged' }); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + + const { stop } = startPoller({ + instanceUrl: 'http://localhost:5000', token: 'cm_test', agentName: 'my-agent', + intervalMs: 20, onEvent, + }); + await new Promise((r) => setTimeout(r, 75)); + stop(); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ + _id: 'wake-1', + payload: expect.objectContaining({ + content: expect.stringContaining('thread 21: 1 unread'), + digestSummary: expect.stringContaining('thread 21: 1 unread'), + }), + })); + expect(onEvent.mock.calls[0][0].payload.digestSummary).toContain('TASK-7: 2 unread'); + expect(onEvent.mock.calls[0][0].payload.digestSummary).toContain('3 ambient updates in 2 conversations'); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('private ambient body'); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('Ava'); + expect(mockPost.mock.calls.map(([url]) => url)).toEqual([ + '/api/agents/runtime/events/wake-1/ack', + '/api/agents/runtime/events/digest-1/ack', + ]); + expect(mockPost).toHaveBeenCalledWith('/api/agents/runtime/events/digest-1/ack', { + result: { outcome: 'no_action', reason: 'digest-read' }, deliveryId: 'digest-nonce', + }); + }); + + test('shows folded-only unread counts to agent connect before acknowledging the wake', async () => { + const wake = { + ...makeEvent('wake-folded'), deliveryMode: 'wake', + payload: { + content: '@my-agent please help', deliveryId: 'wake-nonce', + digestRollup: { + unreadCount: 3, + conversations: [{ label: 'thread 21', count: 2 }], + }, + }, + }; + const mockPost = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ events: [wake] }), post: mockPost, + }); + const onEvent = jest.fn(async (event) => { + expect(mockPost).not.toHaveBeenCalled(); + expect(event.payload.content).toContain('thread 21: 2 unread'); + expect(event.payload.digestSummary).toContain('older pod activity: 1 unread'); + expect(event.payload.digestSummary).toContain('3 ambient updates in 2 conversations'); + return { outcome: 'acknowledged' }; + }); + const { stop } = startPoller({ + instanceUrl: 'http://localhost:5000', token: 'cm_test', agentName: 'my-agent', + intervalMs: 100_000, onEvent, + }); + await new Promise((r) => setTimeout(r, 50)); + stop(); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(mockPost.mock.calls.map(([url]) => url)).toEqual([ + '/api/agents/runtime/events/wake-folded/ack', + ]); + }); + + test.each([ + { outcome: 'error', reason: 'webhook failed' }, + { outcome: 'no_action', reason: 'upstream-refused' }, + ])('leaves digest unread when the waking handler returns $outcome with a reason', async (result) => { + const events = [ + { ...makeEvent('wake-1'), deliveryMode: 'wake' }, + { ...makeEvent('digest-1'), deliveryMode: 'digest', payload: { content: 'ambient' } }, + ]; + const mockGet = jest.fn().mockResolvedValue({ events }); + const mockPost = jest.fn().mockResolvedValue({}); + const onEvent = jest.fn().mockResolvedValue(result); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + const { stop } = startPoller({ + instanceUrl: 'http://localhost:5000', token: 'cm_test', agentName: 'my-agent', + intervalMs: 100_000, onEvent, + }); + await new Promise((r) => setTimeout(r, 50)); + stop(); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(mockPost.mock.calls.map(([url]) => url)).toEqual([ + '/api/agents/runtime/events/wake-1/ack', + ]); + }); + + test('does not forward or acknowledge another pod\'s digest with a wake', async () => { + const events = [ + { ...makeEvent('wake-1'), deliveryMode: 'wake', payload: { content: 'pod A' } }, + { ...makeEvent('digest-2'), podId: 'pod-other', deliveryMode: 'digest', payload: { content: 'private pod B' } }, + ]; + const mockPost = jest.fn().mockResolvedValue({}); + const onEvent = jest.fn().mockResolvedValue({ outcome: 'acknowledged' }); + createClient.mockReturnValue({ get: jest.fn().mockResolvedValue({ events }), post: mockPost }); + const { stop } = startPoller({ + instanceUrl: 'http://localhost:5000', token: 'cm_test', agentName: 'my-agent', + intervalMs: 100_000, onEvent, + }); + await new Promise((r) => setTimeout(r, 50)); + stop(); + + expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ + payload: { content: 'pod A' }, + })); + expect(mockPost.mock.calls.map(([url]) => url)).toEqual([ + '/api/agents/runtime/events/wake-1/ack', + ]); + }); + test('stops after stale_delivery instead of polling against the replacement', async () => { const events = [{ ...makeEvent('stale-1'), payload: { deliveryId: 'e'.repeat(32) } }]; const stale = Object.assign(new Error('This delivery was superseded'), { diff --git a/cli/__tests__/poll-after-turn.test.mjs b/cli/__tests__/poll-after-turn.test.mjs index a62e081c76..ca08b51d0c 100644 --- a/cli/__tests__/poll-after-turn.test.mjs +++ b/cli/__tests__/poll-after-turn.test.mjs @@ -1,17 +1,12 @@ /** * poll-after-turn.test.mjs — TASK-343 * - * A turn is exactly when new events pile up: the person who typed while the - * adapter was running is already waiting by the time it finishes. The loop - * nonetheless slept a full poll interval after every turn before looking - * again, so that person paid the turn plus the interval. + * A turn is when new events pile up. Poll shortly after it ends, while + * collecting notices that arrive mid-turn into one inbox read. * - * Carved out of TASK-289, which traded one interval of greeting latency to put - * a waiting mention first; this removes the interval it was trading. - * - * The rule is narrow on purpose: only a batch that actually spawned looks - * again at once. An idle poll, or a batch that spawned nothing, keeps the - * interval — otherwise the loop spins. + * A successful turn waits three seconds before reading again so notices that + * arrived mid-turn can be collected in one inbox read. An idle poll, or a + * batch that spawned nothing, keeps the normal interval. */ import { jest } from '@jest/globals'; @@ -46,6 +41,7 @@ const { performRun } = await import('../src/commands/agent.js'); const stubAdapter = (await import('../src/lib/adapters/stub.js')).default; const INTERVAL = 5000; +const POST_TURN_BATCH_MS = 3000; const mention = (overrides = {}) => ({ _id: 'evt-mention', @@ -91,13 +87,13 @@ const delayAfterOneTick = async ({ page, spawn }) => { return { delays, spawn, mockPost }; }; -describe('the loop looks again as soon as a turn ends', () => { +describe('the loop batches notices after a turn', () => { beforeEach(() => { jest.clearAllMocks(); fs.rmSync(path.join(sessionsTmpDir, '.commonly'), { recursive: true, force: true }); }); - test('a turn that answered somebody is followed by an immediate poll', async () => { + test('a turn that answered somebody waits three seconds before polling', async () => { const spawn = jest.fn(async () => ({ text: 'looking now' })); const { delays } = await delayAfterOneTick({ page: { events: [mention()], inboxCount: 1 }, @@ -105,7 +101,7 @@ describe('the loop looks again as soon as a turn ends', () => { }); expect(spawn).toHaveBeenCalledTimes(1); - expect(delays).toEqual([0]); + expect(delays).toEqual([POST_TURN_BATCH_MS]); }); test('an idle poll still waits the interval', async () => { diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index 3ae8ae0b40..27e06b7bf2 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -302,6 +302,150 @@ describe('performRun', () => { ); }); + test('a wake carries compact digest context without claiming ambient messages', async () => { + const events = [ + makeEvent({ _id: 'evt-wake', type: 'chat.mention', payload: { + content: 'please review', digestRollup: { unreadCount: 3, lastEventIds: ['old-1', 'old-2'], + conversations: [{ label: 'thread 40', count: 3 }] }, + } }), + makeEvent({ _id: 'evt-digest', type: 'message.posted', deliveryMode: 'digest', + payload: { content: 'daily update', username: 'alice', messageId: '42', threadRootId: 40 } }), + ]; + const get = jest.fn(async (route) => { + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/memory')) return { sections: {} }; + return { events, inboxCount: 2 }; + }); + const post = jest.fn(async () => ({})); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn(async () => ({ text: 'reviewed' })); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_test', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn.mock.calls[0][0]).toContain('[Since you last looked: 4 ambient updates'); + expect(spawn.mock.calls[0][0]).toContain('4 ambient updates in 1 conversation'); + expect(spawn.mock.calls[0][0]).toContain('thread 40: 4 unread'); + expect(spawn.mock.calls[0][0]).toContain('8 conversations, 5 messages each, 20 messages total'); + expect(spawn.mock.calls[0][0]).not.toContain('daily update'); + expect(spawn.mock.calls[0][0]).not.toContain('Event 2/2: message.posted'); + expect(post.mock.calls.filter(([route]) => route.endsWith('/claim'))).toHaveLength(0); + expect(ackPosts(post)).toHaveLength(2); + }); + + test('digest notices group unread threads, dedupe one source, and batch the next read for three seconds', async () => { + const events = [ + makeEvent({ _id: 'wake', type: 'chat.mention', payload: { content: 'review this' } }), + makeEvent({ _id: 'ambient-a', type: 'message.posted', deliveryMode: 'digest', + payload: { messageId: '41', threadRootId: 40, content: 'secret first body' } }), + makeEvent({ _id: 'ambient-a-copy', type: 'message.posted', deliveryMode: 'digest', + payload: { messageId: '41', threadRootId: 40, content: 'secret first body' } }), + makeEvent({ _id: 'ambient-b', type: 'message.posted', deliveryMode: 'digest', + payload: { messageId: '42', threadRootId: 40, content: 'secret second body' } }), + ]; + const get = jest.fn(async (route) => { + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/memory')) return { sections: {} }; + return { events, inboxCount: events.length }; + }); + const post = jest.fn(async () => ({})); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn(async () => ({ text: 'handled' })); + const schedule = jest.fn(); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_test', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', intervalMs: 100, setTimeoutImpl: schedule, + }); + await drainMicrotasks(); + stop(); + expect(spawn).toHaveBeenCalledTimes(1); + const prompt = spawn.mock.calls[0][0]; + expect(prompt).toContain('2 ambient updates in 1 conversation'); + expect(prompt).toContain('thread 40: 2 unread'); + expect(prompt).not.toContain('secret first body'); + expect(prompt).not.toContain('secret second body'); + expect(schedule).toHaveBeenCalledWith(expect.any(Function), 3000); + }); + + test('a digest-only page never starts a turn or acknowledges the ambient event', async () => { + const event = makeEvent({ deliveryMode: 'digest', type: 'message.posted', + payload: { content: 'ambient', messageId: '42' } }); + const get = jest.fn(async () => ({ events: [event], inboxCount: 1 })); + const post = jest.fn(async () => ({})); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn(async () => ({ text: 'should not run' })); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_test', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + expect(spawn).not.toHaveBeenCalled(); + expect(ackPosts(post)).toHaveLength(0); + }); + + test('a digest companion stays unread when the sole wake loses its binding claim', async () => { + const events = [ + makeEvent({ _id: 'evt-held', type: 'message.posted', + payload: { content: 'peer owns this', messageId: 'msg-held' } }), + makeEvent({ _id: 'evt-ambient', type: 'message.posted', deliveryMode: 'digest', + payload: { content: 'background', messageId: 'msg-ambient' } }), + ]; + const get = jest.fn(async (route) => { + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/memory')) return { sections: {} }; + return { events, inboxCount: 2 }; + }); + const post = jest.fn(async (route) => ( + route.endsWith('/claim') ? { claimed: false, claimedBy: 'nova' } : {} + )); + createClient.mockReturnValue({ get, post, del: jest.fn() }); + const spawn = jest.fn(async () => ({ text: 'must not run' })); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_test', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + + expect(spawn).not.toHaveBeenCalled(); + expect(ackPosts(post)).toEqual([[ + '/api/agents/runtime/events/evt-held/ack', + { result: { outcome: 'no_action', reason: 'claim-held' } }, + ]]); + }); + + test('a provider refusal leaves digest unread even though the wake is acknowledged', async () => { + const events = [ + makeEvent({ _id: 'evt-refused', payload: { content: 'question' } }), + makeEvent({ _id: 'evt-ambient', type: 'message.posted', deliveryMode: 'digest', + payload: { content: 'background' } }), + ]; + const get = jest.fn(async (route) => ( + route.endsWith('/messages') ? { messages: [] } : { events, inboxCount: 2 } + )); + const post = jest.fn(async () => ({})); + createClient.mockReturnValue({ get, post }); + const spawn = jest.fn(async () => ({ text: '', upstream: { status: 429, detail: 'budget' } })); + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', token: 'cm_agent_test', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(ackPosts(post).map(([route]) => route)).toEqual(['/api/agents/runtime/events/evt-refused/ack']); + }); + test('a lost binding item stays read-only context while another batch item may run', async () => { const events = [ makeEvent({ @@ -1493,7 +1637,7 @@ describe('performRun', () => { }; await runNext(5000); // first failure → second probe await runNext(10000); // second failure → recovery probe - await runNext(0); // success → look again at once (TASK-343), not after the interval + await runNext(3000); // success → mid-turn notice batch window handle.stop(); expect(spawn).toHaveBeenCalledTimes(4); diff --git a/cli/package-lock.json b/cli/package-lock.json index 0f0d3a089a..b4f281e375 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.152", + "version": "0.1.153", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.152", + "version": "0.1.153", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 777a407bf5..599df7858d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.152", + "version": "0.1.153", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 1ed0b44883..cc9fe42781 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -463,7 +463,10 @@ const DEFERRABLE_SOLO_EVENT_TYPES = new Set(['first_contact']); // Inside one batch the person's item leads the prompt; the greeting follows it. const PERSON_WAITING_EVENT_TYPES = new Set(['chat.mention', 'message.posted', 'dm.message']); -const inboxPriority = (event) => (PERSON_WAITING_EVENT_TYPES.has(event?.type) ? 0 : 1); +const inboxPriority = (event) => { + if (event?.deliveryMode === 'digest') return 2; + return PERSON_WAITING_EVENT_TYPES.has(event?.type) ? 0 : 1; +}; // Consultations have a per-request private response channel, rather than a // pod-chat delivery. They stay on their existing single-event path; every @@ -1506,7 +1509,7 @@ export const performRun = ({ // own conversation (Sage stood down twice on Anvil's thread // replies, 2026-08-24). Interim for TASK-058. const repliesToThisSeat = event.payload?.repliesToYourMessage === true; - if (ADDRESSED_EVENT_TYPES.has(event.type) || repliesToThisSeat) { + if (ADDRESSED_EVENT_TYPES.has(event.type) || event.payload?.addressed === true || repliesToThisSeat) { log( `[${event.type}] message ${claimMessageId} held by ${claim.holder} — ` + (repliesToThisSeat @@ -2008,10 +2011,12 @@ export const performRun = ({ // pull, rather than being silently consumed here. const buildInboxPrompt = (entries, inboxCount = entries.length) => { const BODY_LIMIT = batchLimit; - const bodies = entries.slice(0, BODY_LIMIT).map((entry, index) => { + const wakeEntries = entries.filter((entry) => entry.event.deliveryMode !== 'digest'); + const digestEntries = entries.filter((entry) => entry.event.deliveryMode === 'digest'); + const bodies = wakeEntries.slice(0, BODY_LIMIT).map((entry, index) => { const messageId = entry.event.payload?.messageId; const prefix = [ - `Event ${index + 1}/${entries.length}: ${entry.event.type}`, + `Event ${index + 1}/${wakeEntries.length}: ${entry.event.type}`, messageId ? `message ${messageId}` : null, ].filter(Boolean).join(' — '); const claimContext = entry.readOnly @@ -2021,11 +2026,43 @@ export const performRun = ({ const body = entry.prompt || '[No usable prompt was delivered for this item.]'; return `${prefix}\n${claimContext}${body}`; }); - const omitted = Math.max(0, inboxCount - bodies.length); + const rollup = wakeEntries.find((entry) => entry.event.payload?.digestRollup)?.event.payload?.digestRollup; + const conversationCounts = new Map(); + let knownOlder = 0; + for (const conversation of Array.isArray(rollup?.conversations) ? rollup.conversations : []) { + if (typeof conversation.label !== 'string' || !Number.isInteger(conversation.count) + || conversation.count < 1) continue; + conversationCounts.set(conversation.label, + (conversationCounts.get(conversation.label) || 0) + conversation.count); + knownOlder += conversation.count; + } + if (rollup?.unreadCount > knownOlder) { + conversationCounts.set('older pod activity', rollup.unreadCount - knownOlder); + } + const seenDigestSources = new Set(); + for (const entry of digestEntries) { + const { threadRootId, taskId, taskKind, messageId, content } = entry.event.payload || {}; + const sourceId = messageId != null ? `message:${messageId}` + : (typeof taskId === 'string' ? `task:${taskId}:${taskKind || ''}:${content || ''}` : null); + if (sourceId && seenDigestSources.has(sourceId)) continue; + if (sourceId) seenDigestSources.add(sourceId); + const label = Number.isInteger(Number(threadRootId)) && Number(threadRootId) > 0 + ? `thread ${threadRootId}` + : (/^TASK-\d+$/.test(String(taskId || '')) ? taskId : 'pod conversation'); + conversationCounts.set(label, (conversationCounts.get(label) || 0) + 1); + } + const digestLines = [...conversationCounts].slice(0, 8) + .map(([label, count]) => `- ${label}: ${count} unread`); + const digestCount = [...conversationCounts.values()].reduce((sum, count) => sum + count, 0); + const omitted = Math.max(0, inboxCount - entries.length); return [ `[Inbox batch: ${inboxCount} new event${inboxCount === 1 ? '' : 's'} in this pod. ` + 'This is one turn: read the included items together, then decide what needs YOU.]', ...bodies, + ...(digestEntries.length > 0 || rollup?.unreadCount > 0 ? [ + `[Since you last looked: ${digestCount} ambient update${digestCount === 1 ? '' : 's'} in ${conversationCounts.size} conversation${conversationCounts.size === 1 ? '' : 's'}; context only, no reply needed.] +${digestLines.join('\n')}${conversationCounts.size > 8 ? `\n- ${conversationCounts.size - 8} more conversations omitted` : ''}\nPull relevant messages with commonly_get_messages. On resume, read oldest unread first; at most 8 conversations, 5 messages each, 20 messages total.`, + ] : []), ...(omitted > 0 ? [`[${omitted} more event${omitted === 1 ? '' : 's'} arrived. Their count is real; pull pod context if needed.]`] : []), @@ -2168,12 +2205,17 @@ export const performRun = ({ ? new Set(preSpawn.map((message) => String(message._id || message.id))) : null; const activeEntries = entries.filter((entry) => !entry.result); - const triggers = activeEntries.map((entry) => classifyTrigger(entry.event, preSpawn)); + // Ambient digest entries provide context for this turn, but must never + // determine its admission or count as the trigger that woke the seat. + const wakingEntries = activeEntries.filter((entry) => entry.event.deliveryMode !== 'digest'); + const triggers = wakingEntries.map((entry) => classifyTrigger(entry.event, preSpawn)); const trigger = triggers.includes('human') ? 'human' : (triggers.includes('agent') ? 'agent' : 'unknown'); if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger); - const admissionEvent = activeEntries.find((entry) => ADDRESSED_EVENT_TYPES.has(entry.event.type)) - || activeEntries[0]; + const admissionEvent = wakingEntries.find((entry) => ( + ADDRESSED_EVENT_TYPES.has(entry.event.type) || entry.event.payload?.addressed === true + )) + || wakingEntries[0]; if (admissionEvent) { const admission = cascadeGovernor.admit( eventPodId, @@ -2182,7 +2224,7 @@ export const performRun = ({ admissionEvent.event.payload, ); if (!admission.allowed) { - for (const entry of activeEntries) { + for (const entry of wakingEntries) { entry.result = batchAdmissionResult(entry.event, admission); } return { entries }; @@ -2190,7 +2232,8 @@ export const performRun = ({ } const bindingEntries = activeEntries.filter((entry) => ( - entry.event.type === 'message.posted' && entry.event.payload?.messageId + entry.event.deliveryMode !== 'digest' + && entry.event.type === 'message.posted' && entry.event.payload?.messageId )); if (bindingEntries.length > 0) { const yieldMs = claimHandicap.yieldDelayMs(eventPodId); @@ -2201,6 +2244,7 @@ export const performRun = ({ } for (const entry of activeEntries) { + if (entry.event.deliveryMode === 'digest') continue; const messageId = entry.event.payload?.messageId; if (!messageId || !CLAIMABLE_EVENT_TYPES.has(entry.event.type)) continue; const claimKeeper = createClaimKeeper(client, { @@ -2224,7 +2268,7 @@ export const performRun = ({ // `message.posted`. Preserve the existing ADR-018/TASK-058 // peer-aware exception; D3a otherwise partitions lost bindings as // read-only so a generic broadcast cannot widen itself after CAS. - if (entry.event.payload?.repliesToYourMessage === true) { + if (entry.event.payload?.repliesToYourMessage === true || entry.event.payload?.addressed === true) { entry.peerFrame = peerHoldsFrame(claim.holder, messageId); continue; } @@ -2241,7 +2285,7 @@ export const performRun = ({ } } - const actionEntries = activeEntries.filter((entry) => !entry.result); + const actionEntries = wakingEntries.filter((entry) => !entry.result); if (actionEntries.length === 0) return { entries }; const claimKeepers = actionEntries @@ -2300,6 +2344,17 @@ export const performRun = ({ trigger, }); for (const entry of actionEntries) entry.result = turnResult; + // A digest was context in this successful waking turn, never an + // independent action. If the waking work stood down or the provider + // refused the batch, leave it unread for the next real turn. + if (turnResult?.outcome === 'posted' + || (turnResult?.outcome === 'no_action' && !turnResult?.reason)) { + for (const entry of activeEntries) { + if (entry.event.deliveryMode === 'digest') { + entry.result = { outcome: 'no_action', reason: 'digest-read' }; + } + } + } } catch (err) { if (!isContentRefusal(err)) throw err; if (actionEntries.length === 1) { @@ -2352,6 +2407,7 @@ export const performRun = ({ const tick = async () => { if (!running) return; let nextPollDelayMs = intervalMs; + let completedModelTurn = false; try { // ADR-024 D3: this is an inbox fetch, not a stream of interrupts. The // backend returns one pod's oldest pending batch, so every delivered row @@ -2392,6 +2448,12 @@ export const performRun = ({ } for (const group of eventGroups.values()) { if (!running) break; + // A current kernel never claims a digest-only page. Guard a mixed + // version rollout too: an ambient batch must not start a model turn. + if (group.every((event) => event.deliveryMode === 'digest')) { + log(`[inbox.batch] digest-only page (${group.length}) — no spawn or ack`); + continue; + } if ((group[0].podId || podId) && !PRIVATE_RESPONSE_EVENT_TYPES.has(group[0].type)) { // Nothing in this batch but the seat introducing itself: leave it // unacked for one interval so a person who mentions the agent in that @@ -2459,13 +2521,9 @@ export const performRun = ({ )); if (batchSpawned) { consecutiveSpawnFailures = 0; - // A turn is exactly when events pile up: whoever typed while the - // adapter was running is already waiting. Sleeping the interval on - // top of the turn charges them for it twice (TASK-343). Only a - // batch that actually spawned skips the wait; an idle poll and a - // batch that spawned nothing keep the interval, so this cannot - // become a spin. - nextPollDelayMs = 0; + // Notices arriving mid-turn get one bounded collection window. + // Idle polls and batches with no model turn keep their cadence. + completedModelTurn = true; } for (const entry of batch.entries) { const event = entry.event; @@ -2643,7 +2701,9 @@ export const performRun = ({ } onError?.(err); } - if (running) setTimeoutImpl(tick, nextPollDelayMs); + // Notices landing during a turn share one three-second collection window + // before the next inbox read, independent of the idle polling cadence. + if (running) setTimeoutImpl(tick, completedModelTurn ? 3000 : nextPollDelayMs); }; tick(); diff --git a/cli/src/lib/poller.js b/cli/src/lib/poller.js index 6c7efae7ac..90e19d2f56 100644 --- a/cli/src/lib/poller.js +++ b/cli/src/lib/poller.js @@ -28,6 +28,40 @@ export const terminalDeliveryAckError = (ackErr, eventId, runner = 'poller') => return null; }; +const digestFrame = (events, rollup) => { + const counts = new Map(); + let knownOlder = 0; + for (const conversation of Array.isArray(rollup?.conversations) ? rollup.conversations : []) { + if (typeof conversation.label !== 'string' || !Number.isInteger(conversation.count) + || conversation.count < 1) continue; + counts.set(conversation.label, (counts.get(conversation.label) || 0) + conversation.count); + knownOlder += conversation.count; + } + if (rollup?.unreadCount > knownOlder) { + counts.set('older pod activity', + (counts.get('older pod activity') || 0) + rollup.unreadCount - knownOlder); + } + const seen = new Set(); + for (const event of events) { + const payload = event.payload || {}; + const sourceId = payload.messageId != null ? `message:${payload.messageId}` + : (typeof payload.taskId === 'string' + ? `task:${payload.taskId}:${String(payload.taskKind || '')}:${String(payload.content || '')}` : null); + if (sourceId && seen.has(sourceId)) continue; + if (sourceId) seen.add(sourceId); + const thread = payload.threadRootId; + const task = payload.taskId; + const label = Number.isInteger(thread) && thread > 0 ? `thread ${thread}` + : (typeof task === 'string' && /^TASK-\d+$/.test(task) ? task : 'pod conversation'); + counts.set(label, (counts.get(label) || 0) + 1); + } + const lines = [...counts].slice(0, 8).map(([label, count]) => `- ${label}: ${count} unread`); + if (counts.size > 8) lines.push(`- ${counts.size - 8} more conversations omitted`); + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + return `[Since you last looked: ${total} ambient update${total === 1 ? '' : 's'} in ${counts.size} conversation${counts.size === 1 ? '' : 's'}; context only, no reply needed.]\n` + + `${lines.join('\n')}\nPull relevant messages with commonly_get_messages. On resume, read oldest unread first; at most 8 conversations, 5 messages each, 20 messages total.`; +}; + export const startPoller = ({ instanceUrl, token, @@ -57,10 +91,30 @@ export const startPoller = ({ limit: 10, }); - for (const event of events) { + // A digest is received context, not a webhook invocation. The runtime + // inbox claims it only alongside a wake from the same pod. + const wakeEvents = events.filter((event) => event.deliveryMode !== 'digest'); + const digestEvents = events.filter((event) => event.deliveryMode === 'digest' + && wakeEvents[0] && String(event.podId) === String(wakeEvents[0].podId)); + const rollup = wakeEvents[0]?.payload?.digestRollup; + const frame = digestEvents.length > 0 || rollup?.unreadCount > 0 + ? digestFrame(digestEvents, rollup) : null; + let digestConsumed = false; + for (const [index, event] of wakeEvents.entries()) { + const attachedDigest = index === 0 && frame !== null; + const forwarded = attachedDigest ? { + ...event, + payload: { + ...(event.payload || {}), + digestSummary: frame, + ...(typeof event.payload?.content === 'string' ? { + content: `${event.payload.content}\n\n${frame}`, + } : {}), + }, + } : event; let result = { outcome: 'no_action' }; try { - result = (await onEvent(event)) || { outcome: 'no_action' }; + result = (await onEvent(forwarded)) || { outcome: 'no_action' }; } catch (handlerErr) { result = { outcome: 'error', reason: handlerErr.message }; } @@ -72,6 +126,11 @@ export const startPoller = ({ result, ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}), }); + if (attachedDigest && (result.outcome === 'posted' + || result.outcome === 'acknowledged' + || (result.outcome === 'no_action' && !result.reason))) { + digestConsumed = true; + } } catch (ackErr) { const terminalError = terminalDeliveryAckError(ackErr, event._id); if (terminalError) { @@ -84,6 +143,26 @@ export const startPoller = ({ } } + if (digestConsumed) { + for (const event of digestEvents) { + try { + const deliveryId = event.payload?.deliveryId; + await client.post(`/api/agents/runtime/events/${event._id}/ack`, { + result: { outcome: 'no_action', reason: 'digest-read' }, + ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}), + }); + } catch (ackErr) { + const terminalError = terminalDeliveryAckError(ackErr, event._id); + if (terminalError) { + running = false; + onError?.(terminalError); + return; + } + onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`)); + } + } + } + consecutiveErrors = 0; consecutiveAuthErrors = 0; } catch (err) { diff --git a/docs/adr/ADR-018-agent-attention-claims.md b/docs/adr/ADR-018-agent-attention-claims.md index c9973a89a6..8a626144e7 100644 --- a/docs/adr/ADR-018-agent-attention-claims.md +++ b/docs/adr/ADR-018-agent-attention-claims.md @@ -418,6 +418,14 @@ D8's "default **off**" becomes **on for every agent a person brings into a pod** Implementation: `backend/services/wakeDefaultService.ts` (the rule), the registry install route (where it is applied), and `agentMentionService.humanOnlyWake` (the human-only gate). +Receiving a message and waking for it are separate operations. An installation +with wake-on-message disabled still receives the message as durable digest +context. It does not run a turn for that message alone. An addressed reply, +claim, owned task or PR reference, explicit mention, or existing board wake can +start a turn and carry the unread digest. Existing wake +defaults remain as specified above; this amendment changes neither install +defaults nor the owner's ability to switch wake-on-message off. + > **Scope boundary.** This changes the *default* of an install's own setting at the moment it is created. It does **not** gate any wake on pod size, member count or workspace shape. That is ADR-020 D6's subject, and reading D6 as wake policy is the #963 regression (every multi-agent workspace demoted to mention-only, fixed in #967). Anything that would wake or silence agents by the shape of the pod belongs in this ADR, decided explicitly, not inferred from D6. ### D8.2 — AMENDMENT (2026-10-23): a board change wakes only the seat whose work changed @@ -440,6 +448,25 @@ pickup of work nobody asked for** — the stalled task at 3am. Those are different jobs; collapsing them would quietly delete the second, which is the one that makes agents feel alive rather than reactive. +### D9.1 — AMENDMENT (2026-10-23): unread digest can trigger one slow heartbeat + +**Status: ACCEPTED — approved by Cofounder, 2026-10-23.** A digest-only +delivery does not start a model turn. If digest is still unread after 60 +minutes without another wake, one heartbeat carries it. No unread digest means +no digest heartbeat. An existing pending wake or a recent completed wake +prevents a duplicate; a failed or refused turn leaves digest unread. This +conditional read opportunity is distinct from D9's autonomous pickup of work. + +### D9.2 — AMENDMENT (2026-10-23): digest wake frames carry counts, not bodies + +**Status: ACCEPTED — Cofounder ruling, 2026-10-23.** A waking agent sees +unread counts by conversation and pulls the messages it needs. The frame +contains no ambient message bodies. Resume catch-up starts with the oldest +unread conversation and stops after eight conversations, five messages per +conversation, or twenty messages total. Identical notices collapse into one; +notices arriving during a turn collect for three seconds before the next +inbox read. D8's receive/wake boundary still applies. + ### D10 — Pod-level shared memory is a separate ADR, provider-pluggable Raised in the same conversation, deliberately not designed here: coordination @@ -464,6 +491,10 @@ footnote. - Drivers: wrapper claims before acting on `chat.mention` / wake-on-message; stands down on CAS loss. - Tasks: add `claimExpiresAt` to `Task`, honoured by the existing claim path. +- Digest retention: compact expired bodies into per-seat, per-pod unread + counters before deleting them. GC reads 100-row pages within a ten-second + budget, retaining unread order and retrying deferred rows next pass. A + contended scope does not stop other scopes from compacting. ## Out of scope